A JSON Debugging Checklist: Why Valid-Looking JSON Throws
The config that was "definitely valid JSON"
At 2:40 in the afternoon a feature-flag rollout went sideways. The deploy pipeline read a JSON config file, the service crashed on boot, and the on-call dashboard lit up with SyntaxError: Unexpected token } in JSON at position 412. The engineer who wrote the change swore the file was valid. He had pasted it into an online linter. He had even run it through his editor's "format document" command without complaint. And yet JSON.parse refused it in production.
The file was valid JavaScript. It was not valid JSON. That distinction is the single most expensive source of confusion I have seen around this format, because the two languages overlap just enough to lull you into trusting your eyes. JSON is a strict subset described by RFC 8259, and the subset removes precisely the conveniences a JavaScript developer reaches for by reflex: trailing commas, comments, unquoted keys, single quotes, and bare NaN. Your editor's JS-flavored formatter is happy with all of those. The parser at the other end of the wire is not.
What I want to hand you here is the diagnostic checklist I now run, in order, whenever valid-looking JSON throws. It is ordered most-common-first, so if you work top to bottom you usually find the culprit in the first three items. Each entry is the same shape: the symptom you see, why it happens, and a one-line fix. When you hit step 6 you will also understand why the error message itself keeps lying to you about where the problem is.
1. Trailing comma
Symptom: Unexpected token } in JSON at position N or Unexpected token ] ..., where the position points at the closing bracket.
Why: [1, 2, 3,] and {"a": 1,} are both perfectly legal JavaScript array/object literals. RFC 8259 Section 5 defines an array as values separated by commas with no provision for a dangling one, so a comma immediately before ] or } is illegal JSON. This is the number-one offender by a wide margin, because every JS developer types trailing commas all day to keep their git diffs clean.
Fix: Delete the comma before the closing bracket.
JSON.parse('[1, 2, 3,]'); // SyntaxError: Unexpected token ] ...
JSON.parse('[1, 2, 3]'); // [ 1, 2, 3 ] ✓
2. Unquoted or single-quoted keys
Symptom: Unexpected token pointing at the key, or at the first character of a string value.
Why: In JSON, object keys must be double-quoted strings, and so must string values. {name: "Ada"} and {'name': 'Ada'} are valid JS object literals but invalid JSON — RFC 8259 Section 7 specifies that strings begin and end with the quotation mark U+0022 (the double quote) and nothing else. Single quotes are simply not a string delimiter in JSON. This bites hardest when someone hand-writes a payload, or when a config was copied out of a .js file.
Fix: Double-quote every key and every string value.
JSON.parse("{'name': 'Ada'}"); // SyntaxError
JSON.parse('{"name": "Ada"}'); // { name: 'Ada' } ✓
3. Comments
Symptom: Unexpected token / in JSON at position N.
Why: JSON has no comment syntax. None. Both // line and /* block */ are forbidden, which surprises people coming from JSONC, JSON5, or tsconfig.json (those are dialects parsed by bespoke loaders, not by JSON.parse). The first / the standard parser meets is a token it cannot start a value with, so it stops. I have watched an entire afternoon vanish because a teammate annotated a deploy config with // TODO: bump timeout and the loader was the stock library, not the editor's tolerant one.
Fix: Strip the comments, or move documentation into a sibling "_comment" key that your code ignores.
4. NaN, Infinity, and undefined
Symptom: Unexpected token N / Unexpected token I / Unexpected token u.
Why: JSON numbers are finite. The grammar in RFC 8259 Section 6 admits an optional minus, an integer part, an optional fraction, and an optional exponent — and that is the whole story. There is no NaN, no Infinity, no -Infinity, and no undefined. These leak in when something serializes JavaScript floats naively, because a runtime computation produced a NaN and a hand-rolled serializer wrote the literal four letters into the output.
Fix: Decide on a sentinel before serializing — null, a string "NaN", or omit the field. Note that JSON.stringify already does the right thing here: it converts NaN and Infinity to null for you. The bug almost always lives in non-stringify code paths.
JSON.stringify({ x: NaN, y: Infinity }); // '{"x":null,"y":null}'
JSON.parse('{"x": NaN}'); // SyntaxError: Unexpected token N
5. BOM and other invisible bytes
Symptom: Unexpected token at position 0 or position 1, on a file you can stare at all day without seeing anything wrong. Sometimes reported as a token that looks like the opening { itself.
Why: A UTF-8 byte-order mark — the three bytes EF BB BF, decoded as the codepoint U+FEFF — is silently prepended by some Windows editors and PowerShell's default Out-File. Strict parsers, including V8's JSON.parse, treat that leading character as content and refuse it, because RFC 8259 Section 8.1 explicitly says implementations must not add a BOM and may ignore one only at their discretion. V8 chooses not to ignore it. Other invisible offenders in this family are a stray U+200B zero-width space pasted from a webpage, or a non-breaking space U+00A0 masquerading as a plain space between tokens.
Fix: Strip the BOM before parsing.
const clean = raw.replace(/^/, '');
JSON.parse(clean); // ✓
When a file parses for one teammate and throws for another on the same bytes, this is almost always the answer — their editors disagree about whether to write the BOM. To see whether invisible bytes are the issue, paste the raw text into the JSON formatter; if it reports a parse failure at position 0 on text that visually looks fine, you are looking at a leading invisible byte.
6. The "position N" offset lie
This one is less a bug than a misreading, and it costs more time than all the others combined once you have eliminated steps 1 through 5.
Symptom: The error says position 412, you jump to character 412, and the JSON there looks perfectly fine.
Why: position N is not where the mistake is. It is where the parser gave up — the first point at which the accumulated input could no longer form a valid document. If you forget a comma between two object members on line 9, the parser keeps happily consuming tokens until it reaches the next structural character that makes no sense, which might be the closing brace 30 lines and 400 characters later. The offset points at the symptom, not the cause. The real defect is almost always earlier than the reported position — a missing comma, an unclosed bracket, or an unterminated string that the parser only noticed once it ran out of road.
Fix: Treat position N as an upper bound and scan backward from it. Look for the nearest missing comma, missing colon, or unbalanced bracket between the previous valid value and the reported position. A pretty-printer turns this from guesswork into a glance: indentation that suddenly fails to close, or two values sitting on adjacent lines with no comma between them, jump out visually in a way they never do in a single-line blob.
// The missing comma is between the two members,
// but the error fires at the final closing brace.
const bad = '{ "a": 1 "b": 2 }';
JSON.parse(bad); // SyntaxError: Unexpected string in JSON ... (points near "b")
A subtler trap: duplicate keys never throw
There is one failure mode this checklist deliberately does not catch with a SyntaxError, because it produces no error at all. Duplicate keys are not illegal:
JSON.parse('{"role": "user", "role": "admin"}');
// { role: 'admin' } — last one silently wins, no error
RFC 8259 Section 4 says object names should be unique but does not require it, and JSON.parse resolves the ambiguity by keeping the last occurrence and discarding the rest, silently. I have seen this exploited as a real privilege-escalation vector: a request body with {"role":"user","role":"admin"} where the security check reads the first role and the business logic reads the last. If you decode tokens or inspect request payloads, run them through the JWT decoder and a formatter and count your keys — a parser that drops half your object without complaint is worse than one that throws, because nothing tells you it happened.
The round-trip that quietly eats your data
The last item is not a parse error either, but it lands in the same debugging session often enough to belong here. The popular JSON.parse(JSON.stringify(x)) deep-clone idiom is lossy by design. undefined values, functions, and Symbol keys vanish entirely; NaN and Infinity become null; Date objects come back as strings. So if an object goes into a serialization boundary looking one way and comes out the other side missing fields, the data was not corrupted in transit — it was dropped at stringify time, because those types have no JSON representation at all.
JSON.parse(JSON.stringify({ a: undefined, b: () => 1, c: NaN }));
// { c: null } — a and b are gone, c is coerced
How I actually run this now
In practice the loop is fast. I paste the failing text into the JSON formatter first, because pretty-printing collapses steps 1, 2, 3, and 6 into a single visual scan — a trailing comma, an unquoted key, a stray //, and an unclosed bracket all become obvious once the structure is indented and the failing line is highlighted. If the formatter chokes at position 0 on text that looks clean, I go straight to step 5 and strip the BOM. Only after the structure parses do I worry about semantics like duplicate keys.
The deeper lesson from that 2:40 incident: "it looked valid" and "my editor didn't complain" are statements about JavaScript, not about JSON. The parser enforces a stricter grammar than your eyes do, and once you internalize the six items above — trailing comma, unquoted keys, comments, NaN, invisible bytes, and the position-offset lie — most "impossible" parse errors resolve in under a minute. The format is small. The traps are few. They are just very, very good at hiding in plain sight.
Tools used in this guide
- JSON Formatter — Paste JSON, validate it, format it with indentation, or minify it into compact output for APIs and config files.
- JWT Decoder — Paste a JSON Web Token and inspect its header, payload, and signature segment locally in your browser.