A JSON integer above 2^53 does not survive a round trip through JSON.parse. It comes back as a different number, with the same type, no exception and no warning. A record that matched a row in the database stops matching, and the code consuming it has no signal that anything moved.
The measurements below ran on Node v26.12.1. They locate the boundary, show where a JSON.parse reviver helps, and show the two places where it does not.
The smallest case that fails
const parsed = JSON.parse('{"id": 9007199254740993}').id;
console.log(parsed); // 9007199254740992
console.log(typeof parsed); // "number"
console.log(String(parsed) === "9007199254740993"); // false
No exception, no deprecation warning, no field flagged as suspicious. The value is off by one and the type is still number, so every typeof check in the codebase keeps approving it. The same literal written directly in source code is also already wrong before it reaches JSON:
console.log(String(9007199254740993)); // "9007199254740992"
2^53 is the boundary, and above it the step is 2
A JavaScript number is an IEEE 754 double with 53 bits of mantissa. The largest integer that survives a round trip is Number.MAX_SAFE_INTEGER, which is 9007199254740991. The next power of two, 9007199254740992, is still exactly representable, but it is no longer safe:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(2 ** 53); // 9007199254740992
console.log(Number.isSafeInteger(2 ** 53)); // false
Above 2^53 the distance between two representable numbers is 2, so even integers are exact and odd integers are not. An odd id lands on a neighbour, and the parser has no way to know it did.
I generated 1000 random integers above 2^53 and compared each with the value returned by JSON.parse. Two runs:
- run 1: 505 unchanged, 495 changed, and 505 of the samples were even while 495 were odd;
- run 2: 500 unchanged, 500 changed, with the same even and odd counts.
The parity match is the mechanism showing itself. Even input stays, odd input moves. The "changed" number in a report should not be read as a fixed percentage of your data, because it depends on whether your ids are even or odd.
Two different ids can become one key
Rounded values do not only disagree with the database. They also collide with each other, and a collision inside a Set or a Map merges records that the source system kept separate.
const a = JSON.parse('{"id": 9007199254740992}').id;
const b = JSON.parse('{"id": 9007199254740993}').id;
console.log(a === b); // true
I sent 1000 consecutive integers starting at 2^53 and counted how many distinct values survived:
- 1000 consecutive ids above 2^53 produced 501 distinct values, so 499 keys were lost;
- 1000 consecutive ids below 2^53 produced 1000 distinct values.
Deduplication, Map lookups and "have I seen this record" checks all assume one id per record. Above 2^53 that assumption is untrue, and nothing in the standard library objects.
A reviver fixes the leaf, not the container
Modern V8 passes a third argument to the reviver function: a context object whose source holds the original text of the value being revived. That text has not been through the double, so it can be turned into a BigInt without loss.
const exato = JSON.parse('{"id": 9007199254740993}', (chave, valor, contexto) =>
chave === "id" && contexto && contexto.source ? BigInt(contexto.source) : valor
);
console.log(String(exato.id)); // "9007199254740993"
What matters is what the reviver receives. I logged every call for one payload:
JSON.parse('{"id": 9007199254740993, "ids": [9007199254740992, 9007199254740993]}',
(k, v, ctx) => { console.log(k, typeof v, ctx && ctx.source); return v; });
The output, in order:
- key
id, typenumber, source"9007199254740993"; - key
0, typenumber, source"9007199254740992"; - key
1, typenumber, source"9007199254740993"; - key
ids, typeobject, no source; - key
""(the root object), typeobject, no source.
Leaves carry the raw text, including elements inside an array. Containers do not. That ordering is why a reviver written at the wrong level looks like it runs and does nothing.
A reviver placed at the array level shows the trap:
// wrong level: v is already [9007199254740992, 9007199254740992]
const tarde = JSON.parse('{"ids": [9007199254740992, 9007199254740993]}', (k, v) =>
Array.isArray(v) ? v.map(String) : v
);
console.log(tarde.ids); // ["9007199254740992", "9007199254740992"]
The strings are produced, the two values are identical, and the bug survives with a cast applied to it. The children are revived before the parent, so the fix has to sit where the primitive still carries its source: k === "id", or typeof v === "number" && ctx.source.length > 15, or an explicit list of keys you know are identifiers.
What the reviver does not fix
Three limits came out of the same measurement, and they decide whether the reviver is worth using at all.
BigInt breaks the way back. A BigInt id cannot go through JSON.stringify without help. JSON.stringify({id: 9007199254740993n}) throws TypeError: Do not know how to serialize a BigInt. You need a replacer that converts to a decimal string, and then the payload is no longer the same shape it arrived in.
A value already parsed stays rounded. The reviver runs during parsing. If your HTTP client, your SDK or your framework parsed the body first, a second parse of the reserialized text cannot recover anything. This is the common case in application code: the client hands you an object and the raw text is gone.
Ids nested in arrays of objects need a leaf rule, not a container rule. The reviver sees the array element with its source, so a rule that inspects the key path works, but a rule that inspects the parent does not.
The fix I would pick first: keep ids as strings
Everything above is a consequence of accepting identifiers as JSON numbers. A quoted id round trips exactly through JSON.parse:
console.log(JSON.parse('{"id": "9007199254740993"}').id); // "9007199254740993"
The string form trades one comparison operator for the entire failure mode: it does not round, it does not collide, it does not depend on parity, and it survives any number of parse and stringify passes. A 64 bit id space reaches about 1.8e19, roughly 2000 times the safe integer limit, so a generator using the full range crosses 2^53 as it grows.
I have also seen schemas disagree about the type of the same identifier: one tool definition types the field as a string, another as an integer, and a strict validator rejects the call when you send the wrong one. Handling ids as strings on both sides ends the guessing, and the string survives the boundary that the number does not.
If a numeric id has to stay numeric for a legacy consumer, do the conversion at the edge, right after reading the response, and keep the string next to it for logging. The moment a rounded number reaches a log line, you lose the ability to tell whether the mismatch is in your code or in the data.
The test that catches this
A round trip test with the fixture {id: 42} passes on any implementation, including one that would break on the first real id. The fixture is what has to change:
const acima = "9007199254740993";
const volta = JSON.parse(`{"id": ${acima}}`, (k, v, ctx) =>
k === "id" && ctx && ctx.source ? BigInt(ctx.source) : v
);
console.assert(String(volta.id) === acima, "id perdeu precisão no parse");
Two rules make that test worth having. The fixture has to sit above 2^53, because below it the bug cannot appear. And the same test has to fail on the unfixed parser, otherwise you are asserting that your test data is small, not that the parser is correct. I ran the control on purpose: the same payload through plain JSON.parse returns a value different from acima.
When an id mismatch shows up again, print the raw response body before printing the parsed object. The parsed object is the evidence that misleads, because it looks exactly like a valid record.
Se você já perdeu tempo comparando um id no log com o mesmo id no banco e encontrou números diferentes, conta nos comentários como descobriu a causa. Respondo em português.
Top comments (0)