JSON Numbers That Change Value When You Beautify Them
The bug you will not see in review
Paste this into the formatter:
{"n":9007199254740993}
It comes back as:
{
"n": 9007199254740992
}
No error, no warning, no status message. The last digit changed. This is not a bug in the formatting step — it is what JSON.parse returns, and any tool that parses and reprints is forced to choose a representation for a number it can no longer distinguish from its neighbour.
The boundary is exact. Subtract one and it round-trips:
| Input | Output | Changed |
|---|---|---|
9007199254740991 | 9007199254740991 | no |
9007199254740993 | 9007199254740992 | yes |
9007199254740991 is 2^53 − 1, the largest integer a double represents exactly. Above it, doubles are spaced more than 1.0 apart, so odd integers stop existing. The number was never in the parse result — the text was the only place it was ever exact.
A formatter that shows you a different number than you pasted is not doing something exotic. It is doing the only thing a JavaScript formatter can do once it has parsed.
Who actually gets hurt
Twitter-style snowflake IDs, Discord IDs, Stripe object IDs, Shopify gids, Postgres bigserial values, Unix nanosecond timestamps, and money in cents all exceed 2^53 routinely and are frequently shipped as JSON numbers rather than strings. This is the reason the "always encode IDs as strings" convention exists. If your API returns "id": 7312345678901234567 as a number, it is already corrupt before the formatter touches it; the formatter just makes it visible.
Wider values are worse, because the corruption is not just the last digit:
| Input | Output |
|---|---|
12345678901234567890 | 12345678901234567000 |
1e309 | null |
-0 | 0 |
1e21 | 1e+21 |
1e309 is Infinity when parsed, which JSON.stringify has no representation for, so it becomes null. A giant number turning into null through a display tool is the failure mode to remember.
-0 becoming 0 is the quiet one: it is a value change that no test you would write catches, and it matters if you distinguish signed zero.
1e21 is not corruption — 1e+21 is the same double. Below 21 digits the exponential form is exact. The formatting rule that triggers it is JavaScript's Number.toString, not the tool's choice.
What this tool gets right
The cases where data must not move, it does not move:
IN {"a":1,"a":2} OUT {"a": 2}
IN {"s":"\ud800"} OUT {"s": "\ud800"}
IN {"a":"b\nc\td"} (min) OUT {"a":"b\nc\td"}
IN {"a":"café"} (min) OUT {"a":"café"}
- Duplicate keys: last one wins, matching
JSON.parse. Unavoidable — the duplicate information is gone at parse time, so a formatter that preserved both would be inventing a JSON extension. - Lone surrogate: re-emitted as an escape, not written out as a raw broken code unit. Verified by reading codepoints off the output: it comes back as
\ud800, six characters of escape text. - Escapes and Unicode: minify keeps
\nand\tescaped, and leavescaféas literal UTF-8 rather than inflating it to\u00e9. Both are the smaller and more readable choice.
Keeping your numbers exact
If you need the digits preserved, the fix is at the parse layer, not the display layer:
- Serialize IDs as strings in your API. The permanent fix, and the one that survives every other tool in the chain.
- Parse with a reviver that catches the raw text.
JSON.parsegives a reviver only the parsed value, so this alone does not work — you need the source text, which means a parser that hands you it (json-bigint,lossless-json, or a SAX-style reader). - Never treat a beautifier as a source of truth. If a number matters, diff it against the bytes you received, not against what the pretty box showed.
A practical check for the data you already have: if you find any integer above 9007199254740991 in a JSON payload, assume everything downstream of a JSON.parse has a wrong value, not just the display copy.
Limits of what I checked
Stack depth is the other cliff, and I only bounded it: [ repeated 1000× round-trips, 10000× throws SyntaxError: Maximum call stack size exceeded inside V8 (13.6.233.17-node.53, Node 24.21.0) before this tool's own code runs, so the failure is the engine's. I did not find the exact depth where it tips over, and did not test the autoRepairJson path against any of these cases — nothing here describes repair behaviour.
How to re-run this
Every output above came from the shipped validateAndFormat / minifyJson, loaded under Node by the repo's fuzz harness with no changes to the deployed code:
node -e 'process.env.FUZZ_APP_ROOT="/tmp/fuzzroot";
const {loadApp}=require("./scripts/fuzz/load.js");
const A=loadApp("jsonbeautify",["validateAndFormat"]).api;
A.__setInput("jsonInput",JSON.stringify({n:9007199254740993}));
A.__setInput("jsonOutput","");A.validateAndFormat();
console.log(A.__readOutput("jsonOutput"));'
# → { "n": 9007199254740992 }
The equivalent in plain Node, if you want to see that this is the engine and not the page: JSON.stringify(JSON.parse('{"n":9007199254740993}')) → {"n":9007199254740992}.