Why "Unexpected Token" Is Almost Never a Typo in Your JSON

The worst JSON parse error I have debugged took four hours and turned out to be a load balancer. The service was returning 200 OK, the client was calling JSON.parse(), and every few hundred requests it threw Unexpected token '<'. We read the serialiser. We read the schema. We added logging to the endpoint that produced the JSON, and the logs were spotless — because the JSON never reached the client. A misconfigured health check was occasionally handing back an HTML maintenance page with a 200 status, and our code cheerfully tried to parse it.

That is the pattern I have seen over and over since: when a parser complains about your JSON, the JSON is usually fine. Something upstream replaced it, truncated it, or prefixed it with a byte you cannot see. The syntax errors people write about — trailing commas, single quotes, unquoted keys — are real, but you catch those in the editor. The ones that survive to production are the ones where the input is not what you think it is.

The Error Message Changed and the Advice Did Not

Search for Unexpected token < in JSON at position 0 and you get a decade of answers written for a message that modern V8 no longer produces. The messages were rewritten: instead of a bare offset, you now get a snippet of the offending input. Here is what Node 26 actually says for the same five inputs:

JSON.parse('<!DOCTYPE html>\n<html>')
// SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

JSON.parse('{"name": "Ada",}')
// SyntaxError: Expected double-quoted property name in JSON at position 15 (line 1 column 16)

JSON.parse(undefined)
// SyntaxError: "undefined" is not valid JSON

JSON.parse('')
// SyntaxError: Unexpected end of JSON input

JSON.parse("{'a': 1}")
// SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)

Two different message shapes, and the difference matters. When V8 can name the rule you broke, it gives you a position with line and column — that is a genuine syntax error inside real JSON. When it falls back to "..." is not valid JSON with a quoted snippet, it is telling you the input does not look like JSON at all. The snippet is the most useful thing on the screen and most people skim past it.

Other runtimes report the same failures very differently:

Runtime Input {"a": 1,} Position info BOM diagnosis
Node / V8 Expected double-quoted property name in JSON at position 15 (line 1 column 16) offset + line/column generic "unexpected token"
Python Illegal trailing comma before end of object: line 1 column 8 (char 7) line, column, char offset names it: Unexpected UTF-8 BOM (decode using utf-8-sig)
PHP Syntax error none none

PHP is the outlier and it is worth knowing before you burn an afternoon: json_last_error_msg() returns the string Syntax error for every structural problem, and JSON_THROW_ON_ERROR throws a JsonException with that same message and code 4. No offset, no line, no hint. If you are debugging a payload in PHP, paste it into a JSON formatter and validator — the browser will point at the character PHP refused to name.

A < in the Snippet Means You Are Not Parsing JSON

If the first character of the snippet is <, stop reading your serialiser. You received HTML. In production the usual sources are:

  • An authentication redirect. The session expired, the gateway sent a 302 to a login page, and your HTTP client followed it. Status code: 200. Body: a login form.
  • A framework error page. The API threw, and the framework's debug handler rendered a nice HTML stack trace instead of a JSON error envelope.
  • A proxy, WAF or rate limiter. Cloudflare challenge pages, nginx 502 pages and corporate proxy blocks are all HTML.
  • A 404 that isn't yours. A typo in the path hits the CDN's catch-all rather than your router.

The fix is not a better parser, it is refusing to parse blindly. A client that checks the content type turns four hours of confusion into one clear log line:

async function fetchJson(url, options) {
    const res = await fetch(url, options);
    const type = res.headers.get('content-type') ?? '';

    if (!type.includes('application/json')) {
        const preview = (await res.text()).slice(0, 200);
        throw new Error(
            `Expected JSON from ${url}, got ${res.status} ${type || 'no content-type'}: ${preview}`
        );
    }

    return res.json();
}

Two hundred characters of the raw body in the error message is the single highest-value change you can make to an HTTP client. Every subsequent debugging session starts with the answer already in the log.

The Failures You Cannot See in an Editor

Three classes of input look perfectly valid on screen and fail anyway.

A byte order mark. Some editors and most Windows tooling prepend EF BB BF to UTF-8 files. Your JSON viewer hides it, cat hides it, and the parser chokes on the first byte. Node reports it as an unexpected token with a snippet that looks like it starts with {. Python is the only one of the three that tells you the truth:

json.loads('{"a": 1}')
# JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)

Confirm it with head -c 3 file.json | xxd before you doubt anything else. If the first three bytes are efbbbf, that is your bug — read the file with utf-8-sig in Python, or strip the prefix.

A truncated body. Unexpected end of JSON input means the document ended early, which almost never means the writer stopped early. It means a proxy timeout cut the response, a Content-Length mismatch made the client stop reading, or a streamed response was killed mid-flight. Compare response.headers.get('content-length') with the actual byte length you received; if they disagree, the network ate your payload and no amount of parser configuration will help.

Double encoding. A JSON string containing JSON — "{\"a\": 1}" — parses fine and gives you a string, not an object. Every so often a service json_encodes an already-encoded payload, and the consumer's first JSON.parse() succeeds while every property access returns undefined. If your parse succeeded but the result is a string, you need a second parse and, more usefully, a conversation with whoever built the producer.

When the Producer Wrote Something JavaScript Will Not Read

One snippet is worth recognising on sight:

JSON.parse('{"a": NaN}')
// SyntaxError: Unexpected token 'N', "{"a": NaN}" is not valid JSON

NaN is not in the JSON grammar, but Python's json module emits it by default, so a Python service can produce a document that its own JavaScript consumer refuses. If the snippet in the error shows a bare NaN, Infinity, or a single-quoted key, stop debugging the reader and go and look at the writer — the fix is allow_nan=False at the point of encoding, not a more forgiving parser downstream. Which runtime does what with these edge cases, and the ones where both sides parse happily and disagree about the value, is measured across PHP, Node, Python and Go.

It is also an argument for YAML being the wrong reflex for config files that cross language boundaries — YAML's type coercion has a longer list of these surprises, which I went through in JSON vs YAML.

Reading the Position Number Properly

When you do get an offset, use it. The mistake is eyeballing a 40 KB minified payload for "position 15420" — slice it instead:

try {
    JSON.parse(raw);
} catch (err) {
    const match = /position (\d+)/.exec(err.message);
    if (match) {
        const pos = Number(match[1]);
        console.error('context:', JSON.stringify(raw.slice(Math.max(0, pos - 40), pos + 40)));
        console.error('char   :', JSON.stringify(raw[pos]));
    }
    throw err;
}

Note the JSON.stringify around the slice: it escapes control characters, so an unescaped newline or a stray \t inside a string literal becomes visible rather than rearranging your terminal output. Unescaped control characters in strings are a common failure when someone builds JSON with string concatenation instead of a serialiser, and they are invisible in every other form of printing.

Also note that the regex is doing work the message format no longer guarantees. Do not build tooling that depends on parsing V8 error text — it changed once and can change again. Use it interactively, not in a retry path.

The Ones That Do Not Throw At All

Worth saying once, because it reframes the whole exercise: a successful parse is not a correct parse. Large integers lose precision in JavaScript, duplicate keys collapse to whichever value came last, and no error is raised anywhere in the chain. If the parse succeeded but the data is wrong, the tool you want is not a validator but a JSON diff between what you sent and what arrived.

The Order I Debug In

Work outside in, not inside out. Check the HTTP status code and content type before you look at a single character of the body. Log the first 200 bytes raw. If it starts with <, the problem is routing, auth or a proxy, not JSON. If it looks like JSON, check for a BOM with head -c 3 | xxd and compare the received length against Content-Length. Only then read the parser message: a position with a line and column means a real syntax error worth slicing to; a quoted snippet with is not valid JSON means the input was never JSON in the first place. In four hours of debugging that load balancer, every minute after the first ten was spent because we assumed the payload was ours.

More Articles

Your Link Preview Is Broken Because the Crawler Never Saw the Page

Blank or wrong link previews are usually a crawler problem, not a tag problem: bot protection, client-side rendering, WebP images - and how to reproduce it.

23 August, 2026

Same Lightness, 12× the Brightness: HSL vs OKLCH

At HSL lightness 50% the measured luminance swings 12.9x across hue. At OKLCH lightness 0.62 it swings 1.2x. Here is the maths, and what it costs you.

20 August, 2026

Claude Now Watermarks Everything It Writes, and There Is No Flag to Turn It Off

Anthropic swapped the sampler's randomness for a keyed choice. The mechanism, the length threshold, what survives editing, and why there is no opt-out.

16 August, 2026