The spec allows it; the runtime does not
RFC 8259 does not put a size limit on numbers. It explicitly allows implementations to set limits on range and precision, and then warns that integers outside ±(2^53 − 1) “indicate potential interoperability problems”. That sentence is the whole story: the format permits a 20-digit integer, and the runtime almost everyone parses it with cannot hold one.
JavaScript has exactly one numeric type. JSON.parse converts every number literal to a 64-bit float, so a 19-digit ID becomes an approximation before your code ever sees it. Nothing about the document was malformed — the container was too small.
Where the boundary is
Number.MAX_SAFE_INTEGER is 9007199254740991, which is 2^53 − 1. An integer at or below that is represented exactly. The next integer, 9007199254740992, is 2^53 and is still exact, but 9007199254740993 does not exist as a double — it rounds down to 9007199254740992, and so does every odd number after it until the next power of two.
This is why the failure is so easy to miss: an 18-digit snowflake ID is well inside the range of a 64-bit integer and well outside the range of a double. APIs that use snowflakes — and databases that return 64-bit primary keys — hit this constantly.
What a round trip actually changes
Paste the left column into any formatter, press Format, and you get the middle column. Every row below was produced by running the input through JSON.parse then JSON.stringify.
| Input | After round trip | Why |
|---|---|---|
| {"id":9007199254740993} | {"id": 9007199254740992} | Numbers become IEEE-754 doubles. Integers above 2^53 lose their last digits. |
| {"b":1,"2":2,"1":3} | {"1": 3, "2": 2, "b": 1} | Keys that look like array indices are enumerated first, ascending. That is a JavaScript object rule, not a JSON one. |
| {"a":1,"a":2} | {"a": 2} | Duplicate member names collapse silently: the last value wins and the others are gone. |
| {"x":1.0,"y":1e3,"z":-0} | {"x": 1, "y": 1000, "z": 0} | Numbers are re-printed in their shortest round-trippable form. |
| "\u00e9\/" | "é/" | Escapes are decoded, then only the ones that must be escaped are written back. |
Only the first row is a real information loss. The other four are normalisation: the bytes differ, the meaning does not. That distinction matters when you diff two documents — a diff of formatted output will report changes that no human made.
Object key order is not insertion order
The second row of that table is the one people rarely expect. A JavaScript object enumerates integer-like keys first, in ascending numeric order, then the remaining string keys in the order they were inserted. JSON itself defines no order at all — it is a set of name/value pairs — so any consumer that depends on member order was relying on undefined behaviour to begin with.
If order is part of your payload, it has to live in the data: an array of objects with an explicit position field, not the order the bytes happened to arrive in.
Keeping big integers intact
There are three practical options, in order of how much you should prefer them.
- Make the field a string in the API. This is what most large platforms do.
{"id":"9007199254740993"}is unambiguous, survives every parser, and costs one pair of quotes. Nobody then has to remember the rule. - Intercept the parse with a reviver. A reviver can inspect each value before your code sees it. Recent V8 engines — verified on Node 22.22.2 for this article — pass a third argument exposing the raw literal, which is what makes this reliable rather than guesswork:
- Use a lossless parser. Libraries such as
lossless-jsonkeep the original text of each number and hand you a wrapper type. They work everywhere, at the cost of a dependency and a slightly unusual value type.
JSON.parse(text, (key, value, context) => {
// context.source is the literal exactly as written in the document.
const raw = context?.source;
if (typeof value === "number" && !Number.isSafeInteger(value)
&& raw && /^-?\d+$/.test(raw)) {
return BigInt(raw);
}
return value;
});Feature-detect it rather than trusting it blindly: if context is undefined, the engine predates the feature and the value has already lost its digits — in that case fall back to a string field or a lossless parser.
If you hash or sign JSON
A round trip is not byte-stable, so never re-serialise a payload that was signed. Two documents can be semantically identical and hash differently because of whitespace, key order or number formatting, and a payload that went through JSON.parse and back may no longer match its own signature.
Sign the exact bytes you send: serialise once, hash that string, transmit the same string. On the receiving side, verify before parsing. The JSON Minifier page covers the size side of the same rule.