JSON Patterns and Gotchas: The Bugs That Survive Code Review
JSON's simplicity is a surface property. Underneath are a handful of traps that produce bugs beautiful enough to survive code review — because each one looks like the other layer's fault.
1. The double-encoded string that parses "fine"
APIs that stringify payloads before putting them in a field produce this classic:
{ "payload": "{\"user\":\"adam\",\"admin\":true}" }
It parses — the outer object is valid — but payload is a string, and every consumer needs a second JSON.parse. The visual tell: backslash-quote sequences where structure should be. Paste it into the beautifier and the escaped mess is instantly obvious; a pretty-printed double-encode looks like a file having a seizure. The root cause is almost always a handler doing json: json.dumps(x) where json: x was meant — the same class of bug as the form-vs-JSON 400 documented on our curl sister site.
2. Numbers that change on the journey
JSON.parse('9007199254740993') returns 9007199254740992. Not an error — the number doesn't fit a double, and the parser rounded silently. Your payment amounts, snowflake IDs, and nanosecond counters now differ by one from the database's. Prevention is a schema rule (IDs as strings, money as integer cents), not a parsing trick. See best practices for the full numeric contract.
3. UTF-16 length lies
JavaScript string .length counts UTF-16 code units. Emoji and many non-BMP characters are two units. "👍".length === 2, and a max-length validator that slices at 256 code units has just cut a user's name into a lone surrogate — which then fails to parse on the next request as invalid UTF-8, in a stack trace three services away. Use Array.from(s).length for code points, or Intl.Segmenter for graphemes, and set DB column limits in the same unit your validator uses.
4. \u escapes and the lone-surrogate crash
JSON permits "\ud83d\udc4d" (a surrogate pair written explicitly). Some encoders emit the halves separately from sliced strings, producing invalid sequences that strict parsers reject — the error message is usually unhelpful, but the validator points at the exact column of the orphaned escape. The producer-side fix is never slicing strings before encoding.
5. Prototype pollution via permissive parsers
JSON.parse itself is safe, but merge/clone helpers that walk parsed objects will happily set __proto__.polluted if a payload contains "__proto__": {...}. It's the reason mature libraries added constructor/prototype guards and why structuredClone-style APIs exist. Rule: never deep-merge untrusted JSON into live objects; replace-whole-object or validate-then-assign.
6. Empty string is not null and not absent
{"note": ""} parses everywhere and means whatever the writer's framework decided. Swift decodes it as a missing-optional error; Python's if obj["note"]: calls it falsy; a database NOT NULL column stores it as content. Decide explicitly: which of "", null, and key-absent represent "no value," and enforce it at the boundary, or each language on the wire invents its own answer.
7. The trailing comma in hand-edited configs
{ "a": 1, } is invalid JSON and valid JavaScript, so the config parses in the app that built it and fails in the sidecar that reads it. Every hand-edited config deserves one pass through repair + validate before CI finds it at 2am; the repair view shows why the parser is right.
8. Key case drift: userId vs user_id
Nothing in JSON normalizes casing or naming style, so a GraphQL layer's camelCase meets a PostgREST layer's snake_case in the same payload, and the consumer that reads both is a pile of ?? fallbacks. Enforce one convention per boundary via schema validation (and the beautifier's sort-keys option keeps contract files diffable while you enforce it).
FAQ
Why did my JSON work in Postman but 400 in the app? Postman tolerates trailing commas and comment-stripping on send; strict servers don't. Validate the exact bytes — column-level errors are on the validator page.
Is pretty-printed JSON safe to commit? Yes for diffs; it's the key order churn and 40MB single-line minified blobs that wreck reviews. Pretty + sorted keys is the git-friendly combination.
How do I test for double-encoding in CI? One assertion per fixture: after parse, recursively assert no string value starts with { and parses again. It catches the bug at the producer, not the consumer.