🍱 Lunchbox Hands

json

JSON 'Unexpected Token' Errors: What Each Message Really Means

Decode Chrome, Node, Firefox and Safari JSON.parse errors: trailing commas, single quotes, comments, BOMs, smart quotes, HTML pages, and what JSON.parse lets through.

Unexpected token < in JSON at position 0 is one of the most searched error messages in web development. It names a character and a position, but it doesn’t tell you what went wrong. The message tells you where the parser gave up, not what you did wrong, and the same mistake produces different messages in different engines and even in different versions of the same engine. This post maps the messages people actually see to the handful of real causes behind them, then covers the problems JSON.parse doesn’t report at all. Every V8 message below comes from running JSON.parse in Node 18.20, 20.17 and 22.18. The Firefox messages are quoted from MDN’s JSON.parse error reference. The Safari strings come from WebKit’s JSON parser source, LiteralParser.cpp and LiteralParser.h.

One mistake, three generations of V8 messages

V8 is the engine in Chrome and Node, and it has reworded its JSON errors twice. Here is one trailing comma, {"a": 1, "b": 2,}, in three Node versions:

Node 18:  Unexpected token } in JSON at position 16
Node 20:  Expected double-quoted property name in JSON at position 16
Node 22:  Expected double-quoted property name in JSON at position 16 (line 1 column 17)

The old format names the character where parsing failed (}) and its 0-based offset. The newer format says what the parser expected, and Node 22 adds a 1-based line and column. For some errors the newer V8 quotes the start of your input instead of giving a position:

Node 18:  Unexpected token N in JSON at position 6
Node 22:  Unexpected token 'N', "{"a": NaN}" is not valid JSON

So if a Stack Overflow answer quotes one wording and your console shows another, they’re usually the same error. One thing never changes: the position points at the character that broke the parse, which is often after the mistake. In the trailing-comma case, position 16 is the }, not the comma before it.

Other engines word things differently again. Firefox reports a line and column: the MDN examples read JSON.parse: unexpected character at line 1 column 14 of the JSON data. Safari prefixes every message with JSON Parse error: and, going by the WebKit source, attaches no location to the messages we traced, which include Unexpected identifier "undefined", Unrecognized token '<', Single quotes (') are not allowed in JSON, Property name must be a string literal, and Unexpected EOF.

The decoder table

Each row is an input we actually ran through V8. The Firefox column only fills in where MDN documents that exact case.

What you haveOld V8 (Node 18)Current V8 (Node 22)Firefox (per MDN)
Trailing comma {"a":1,}Unexpected token }Expected double-quoted property nameunexpected character
Single quotes {'a':1}Unexpected token 'Expected property name or '}'expected property name or '}'
Unquoted key {a:1}Unexpected token aExpected property name or '}'
// comment after a valueUnexpected token /Expected ',' or '}' after property value
NaN, undefined, TrueUnexpected token N / u / TUnexpected token 'N', "…" is not valid JSON
Leading zero {"zip": 02134}Unexpected numberUnexpected numberexpected ',' or '}' after property value in object (for 01)
Curly quote {“a”:1}Unexpected token “Expected property name or '}'
Raw tab inside a stringUnexpected token + an invisible tabBad control character in string literal
Byte order mark at startUnexpected token + an invisible char at position 0Unexpected token '', "{…" is not valid JSON
Two objects, one per lineUnexpected token { in JSON at position 8Unexpected non-whitespace character after JSON
An HTML pageUnexpected token < in JSON at position 0Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Empty string or truncated inputUnexpected end of JSON inputUnexpected end of JSON input (empty) / Expected ',' or ']' after array element (truncated)

A few of these are worth spelling out.

Trailing commas, single quotes, unquoted keys, comments. These are all valid in JavaScript and in the supersets people confuse with JSON. JSON5 allows unquoted keys (“an ECMAScript 5.1 IdentifierName”), a single trailing comma, single-quoted strings, comments, hex numbers, and NaN/Infinity. VS Code’s JSON with Comments mode accepts // and /* */ comments and trailing commas (it shows a warning for the commas) in files like settings.json, tasks.json and launch.json. JSON.parse accepts none of this. RFC 8259 has no comment syntax and no trailing commas, and strings are double-quoted only.

NaN, Infinity, undefined. RFC 8259 §6 is explicit: “Numeric values that cannot be represented in the grammar below (such as Infinity and NaN) are not permitted.” These values often come from Python. Its json module writes them by default: json.dumps({'a': float('nan')}) returns {"a": NaN}, and the Python docs say allow_nan defaults to True. Python reads that output back without complaint, but a browser rejects it. Pass allow_nan=False to catch it at the source. True, False and None are the other Python giveaway. They show up when someone print()s a dict instead of calling json.dumps.

The byte order mark. RFC 8259 §8.1 says implementations “MUST NOT add a byte order mark” but “MAY ignore the presence of” one. JSON.parse doesn’t ignore it. Because the character is invisible, the error looks like Unexpected token in JSON at position 0, with apparently nothing between “token” and “in”. That empty-looking gap is the tell. The usual source is a Windows editor saving as “UTF-8 with BOM”.

Smart quotes and invisible spaces. Paste JSON through Word, Slack, or a CMS and " can become “ ”, and ordinary spaces can become non-breaking spaces (U+00A0). A curly quote at least shows up in the error message. A non-breaking space outside a string doesn’t, so the error points at what looks like a normal space.

Raw control characters. §7 says the quotation mark, backslash, and “the control characters (U+0000 through U+001F)” MUST be escaped inside strings. A literal tab or line break inside a string is invalid. Write \t and \n.

Unexpected token <: you didn’t get JSON at all

When the character is < at position 0, don’t look for a typo in your JSON. You received HTML: an error page, a login redirect, an nginx 502, or a single-page app’s index.html served for an API path that doesn’t exist. response.json() reports it the same way JSON.parse does. Current V8 makes this easier to spot by quoting the start of the body: "<!DOCTYPE "... is not valid JSON. Safari reports Unrecognized token '<'. The fix is on the request side. Log response.status and await response.text() before parsing, and check the URL and the Content-Type header.

Two relatives of this error come from calling JSON.parse on something that isn’t a string. JSON.parse converts its argument to a string first, so:

JSON.parse(undefined)  →  Node 18: Unexpected token u in JSON at position 0
                          Node 22: "undefined" is not valid JSON
JSON.parse({a: 1})     →  Node 18: Unexpected token o in JSON at position 1
                          Node 22: "[object Object]" is not valid JSON
JSON.parse(null)       →  returns null (the string "null" is valid JSON)

“Unexpected token o at position 1” is the o in [object Object], which means the value was already parsed. If you see it, delete the JSON.parse call. Unexpected end of JSON input is the other common one. It means the parser hit the end of the text too early. The text might be an empty body (a 204 response, or a fetch that got nothing back) or a truncated download.

Multiple top-level values: it’s probably NDJSON

{"a":1} on line 1 and {"a":2} on line 2 fails with Unexpected non-whitespace character after JSON at position 8. A JSON text is exactly one value (RFC 8259 §2: JSON-text = ws value ws). One-record-per-line files are a different format, NDJSON or JSON Lines, which is parsed line by line. Our NDJSON formatter validates each line on its own and can convert the file to a single array. The format differences are covered in NDJSON is not a JSON array with extra newlines.

A related case: if the file you’re checking is really a YAML config, stop fighting the JSON parser. The YAML 1.2 spec states that its “primary focus was making YAML a strict superset of JSON”, so JSON parses as YAML but YAML doesn’t parse as JSON. Check it with the YAML validator instead.

What JSON.parse accepts without a word

Getting past JSON.parse doesn’t mean the data survived intact. Three things pass without an error:

JSON.parse('{"id": 1, "id": 2}')           // { id: 2 }: the first value is gone
JSON.parse('{"id": 12345678901234567890}')  // { id: 12345678901234567000 }
JSON.parse('[1e400]')                       // [ Infinity ]

Duplicate keys. RFC 8259 §4 only says names “SHOULD be unique”, and notes that when they aren’t, some implementations keep the last pair, some report an error, and some keep all of them. JSON.parse keeps the last one. So does Python’s json.loads: we ran the same input and got {'a': 2}. Neither warns you.

Integers beyond 2^53. RFC 8259 §6 calls integers in [-(2**53)+1, (2**53)-1] interoperable, “in the sense that implementations will agree exactly on their numeric values.” Beyond that range, any parser that uses 64-bit floats rounds silently. 12345678901234567890 is stored as the nearest double, which is exactly 12,345,678,901,234,567,168. JavaScript prints it as 12345678901234567000, the shortest decimal that maps back to the same double. Either way, it’s not the ID you sent. Send large IDs as strings.

Overflow to Infinity. 1e400 is grammatically valid JSON, but it’s too large for a double, so it parses as Infinity. JSON.stringify then writes it out as null.

Deep nesting is the fourth one. RFC 8259 §9 allows parsers to “set limits on the maximum depth of nesting”, and those limits vary widely. In our test, Node 22 parsed 5,000 nested arrays without complaint. Python 3.14’s json.loads handled 100,000 but raised RecursionError at 200,000. A payload that works in one service can fail in the next.

How our validator finds the real problem

Engine messages point where the parser gave up, and Safari gives no location at all, so our JSON validator doesn’t rely on them alone. The logic lives in src/lib/json-validate.ts:

  1. JSON.parse decides whether the input is valid.
  2. A hand-written scanner then walks the text using the JSON grammar. It uses a loop rather than recursion, so deep nesting can’t overflow the stack. When it finds a mistake it can step past (a trailing comma, a comment, a single-quoted string, an unquoted key, NaN), it records the problem and keeps going. That way one pass can list several errors. It stops at the first problem it can’t safely skip, such as a string that never closes.
  3. If the scanner finds nothing but JSON.parse still threw, the validator pulls a location out of the engine message. It recognizes V8’s (line L column C) and at position N and Firefox’s at line L column C of the JSON data.
  4. On valid input, the same scanner reports the silent problems: duplicate keys, integers beyond 2^53 − 1, numbers that overflow to Infinity, and nesting deeper than 100 levels.

Because the scanner knows what went wrong, it points at the actual mistake. For {"a": 1, "b": 2,}, V8 reports column 17 (the }). Our validator reports Trailing comma before } at line 1, column 16, which is the comma. Each error or warning comes with a plain-English explanation. The ones it names specifically are:

  • Errors: empty input, BOM, trailing comma, single quote, curly “smart” quote, unquoted key, unquoted string, // and /* */ comments, # comments, NaN/Infinity/undefined, Python True/False/None, uppercase TRUE/NULL, leading zero, hex/octal/binary literals, malformed numbers (+1, .5, 5., 1e), raw control characters, line breaks inside strings, invalid escapes, unterminated strings, invisible characters (non-breaking space, zero-width space, and similar), missing comma, missing colon (including = or => in its place), multiple top-level values, extra closing brackets, and mismatched brackets.
  • Warnings on valid JSON: duplicate key (with the offset of the first definition), unsafe integer (showing the exact value it will become), overflow to Infinity, and nesting past 100 levels.

It also guesses at the dialect. It labels input as JSONC when comments appear, as JSON5 or a JavaScript object literal when JS-only syntax (such as unquoted keys, single quotes, hex numbers, NaN) shows up alongside at least one other non-JSON feature, and as a Python dict when it sees True/None. Here’s what a pasted JS object produces:

{name: 'Ada', tags: ['x',], // c
}

Unquoted key name — keys need double quotes          line 1, col 2
Single-quoted string                                 line 1, col 8
Unquoted key tags — keys need double quotes          line 1, col 15
Single-quoted string                                 line 1, col 22
Trailing comma before ]                              line 1, col 25
Trailing comma before }                              line 1, col 27
// comment                                           line 1, col 29
→ "This looks like JSON5 or a JavaScript object literal…"

The validator has limits, and they’re worth knowing. For HTML input, the validator reports Unexpected "<" — expected a value with the generic hint “This character cannot appear here in JSON”. It doesn’t say “this is an HTML page”. The error list stops at 50 entries. It checks syntax, not structure: required fields and types are a job for the JSON Schema validator, and inferring a schema from a sample has its own traps, covered in what schema inference cannot know. Everything runs in your browser, and the pasted text isn’t uploaded.

A quick triage routine

  1. Character is <, position 0? You got HTML. Check the status code and URL, not the JSON.
  2. u or o at position 0 or 1, or "undefined" / "[object Object]" in the message? You passed a non-string to JSON.parse.
  3. Unexpected end of JSON input? The body is empty or cut off.
  4. Error at position 0 with an apparently empty token? There’s a BOM. Re-save as plain UTF-8.
  5. Anything else: paste it into the JSON validator and fix every error it lists, starting from the top.
  6. Once it’s valid, read the warnings. Duplicate keys and large IDs are the problems that reach production. Then tidy the output with the JSON formatter. If you’re comparing two versions of a payload, see why JSON diff is not text diff.