Back to News & Insights
JavaScript September 4, 2026 · 7 min read

JSON.parse tells you what broke, but not where

Paste this into your console: try { JSON.parse('{"a":NaN}') } catch (e) { console.log(e.message)...

JSON.parse tells you what broke, but not where

It found the problem. It quoted the document back at you. It named the exact character. And it did not tell you where.

For a nine-character document that's fine, you can see it. For the 260KB file a user just pasted into your editor, "there is an N somewhere" is not an error message, it's a riddle.

V8 has two message shapes, and only one of them has an offset. Here's every case I could think of that people actually hit:

| broken input | position in message? | message | | --- | --- | --- | | {"a":1,} | 7 | Expected double-quoted property name in JSON at position 7 | | {'a':1} | 1 | Expected property name or '}' in JSON at position 1 | | {a:1} | 1 | Expected property name or '}' in JSON at position 1 | | {"a":"x\qy"} | 8 | Bad escaped character in JSON at position 8 | | {"a":01} | 6 | Unexpected number in JSON at position 6 | | {"a":NaN} | none | Unexpected token 'N', … is not valid JSON | | {"a":undefined} | none | Unexpected token 'u', … is not valid JSON | | {"a":True} | none | Unexpected token 'T', … is not valid JSON | | {"a":{"b":{"c":,}}} | none | Unexpected token ',', … is not valid JSON | | {"a": | none | Unexpected end of JSON input |

Look at which half is which. The messages with a position are the syntactic near-misses: a trailing comma, a single quote, a bad escape. The messages without one are NaN, undefined, Python's True, a stray comma in a nested object, and a truncated file.

That second list is what actually arrives in a text box. It's what you get from hand-edited config, from a Python repr pasted into the wrong window, from a download that got cut off. The engine goes quiet exactly when the document is most confusing.

If JSON.parse won't say where it broke, ask it repeatedly. Binary search the prefix: parse text.slice(0, mid), and if it fails with "unexpected end" you're still inside valid JSON, so go right; any other error means you've gone past the break, so go left.

It's slow. Every probe is a full parse of a prefix, so you pay O(n log n) character-reads to find one offset. On a 260KB document that's about 20 reparses.

It's keyed to one engine's prose. To decide "still inside valid JSON" you have to pattern-match the error message. Mine keyed off is not valid JSON, a string that exists only in V8. So the check never matched in Safari, the search declined to run, and every Safari user got an error annotation on line 1 regardless of where the problem was. It failed silently, in one browser, in a way no test caught, because the tests ran in Node.

And Safari is worse than "differently worded". I ran the same ten documents through JavaScriptCore directly. The engine ships on macOS, so you can try this yourself:

It reports a position for none of them. Not for NaN, which V8 also declines, but also not for the trailing comma, the single quote, the unquoted key, the bad escape or the leading zero, all five of which V8 locates exactly:

| input | V8 | JavaScriptCore | | --- | --- | --- | | {"a":1,} | position 7 | Property name must be a string literal | | {'a':1} | position 1 | Single quotes (') are not allowed in JSON | | {a:1} | position 1 | Expected '}' | | {"a":"x\qy"} | position 8 | Invalid escape character q | | {"a":01} | position 6 | Expected '}' | | {"a":NaN} | none | Unexpected identifier "NaN" |

JavaScriptCore's messages are arguably better prose. "Single quotes are not allowed in JSON" beats "Expected property name or '}'". They are also useless for putting a marker in a gutter. On Safari, JSON.parse never tells you where. For anything.

Error.prototype.message is not an API. It is not in the spec, it is not stable across engines, and it changes between versions. V8 rewrote these messages in 2021 and again later. Anything you build on it is a bet that nobody uses another browser.

JSON's grammar fits on a napkin. Rather than asking the engine where the document stops being JSON, walk it and find out directly.

The whole approach is a recursive descent scanner that returns an offset instead of a value: the first character that cannot legally appear where it does, or text.length if the document simply ends mid-value, or -1 if the whole thing parses.

The core trick is that it doesn't build anything. It has no output, no AST, no allocation. It only moves a cursor and throws the offset when the cursor hits something impossible:

Throwing a number as a control-flow signal is the part that looks wrong and is the reason it stays simple: every scan function can bail from arbitrary depth without threading a result type through the whole descent. Catching typeof offset === "number" keeps genuine bugs distinguishable from the signal.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation