JSON.parse silently corrupts 64-bit IDs
published
TL;DR
JavaScript has one number type, IEEE 754 double. JSON.parse produces those, so any integer above 2**53 - 1 comes back as the nearest representable double — a different number, returned without an error.
JSON.parse('{"id":1234567890123456789}').id;
// 1234567890123456800
Fix it on the wire: send 64-bit IDs as JSON strings. If you can’t change the producer, parse with a reviver that reads context.source and returns a BigInt.
The problem
You fetch a record by ID and get a 404. You log the ID. It looks right — same length, same prefix. You paste it into the database and it matches nothing.
Here is the whole bug, on Node v26.2.0:
| JSON literal | What JSON.parse returns | Off by |
|---|---|---|
9007199254740993 | 9007199254740992 | 1 |
1234567890123456789 | 1234567890123456800 | 11 |
12345678901234567890 | 12345678901234567000 | 890 |
Nothing threw. JSON.parse does not have a “this number does not fit” signal, because as far as the language is concerned nothing went wrong: it produced the closest double to the text it was given.
The corruption is stable and it survives re-serialization, which is what makes it so hard to see:
const body = '{"id":1234567890123456789}';
JSON.stringify(JSON.parse(body));
// '{"id":1234567890123456800}'
So a proxy, a logger, or a formatter that round-trips a payload through JSON.parse and JSON.stringify — including any browser-based one — hands the next hop a payload that looks well-formed and carries the wrong ID.
It is also invisible to === on literals, because the literal in your test file is corrupted at parse time too:
9007199254740993 === 9007199254740992; // true
Why it happens
Two separate specs meeting badly.
JSON does not bound numbers. RFC 8259 §6 says only that “an implementation may set limits on the range and precision of numbers accepted”, then names the practical ceiling: “numbers that are integers and are in the range [-(2**53)+1, (2**53)-1] are interoperable in the sense that implementations will agree exactly on their numeric values”. Above that range, two conforming parsers may legitimately disagree.
JavaScript has one numeric type. Every number is a binary64 double, so Number.MAX_SAFE_INTEGER is 9007199254740991. Past it, consecutive integers stop being distinguishable — 2**53 and 2**53+1 map to the same double, and the gap keeps doubling as the exponent grows.
That is exactly the range where the most common ID scheme in modern APIs lives. Snowflake IDs are 64-bit, and at 19 digits they are three orders of magnitude past the safe range. Database bigint columns, Java long, Go int64 and Rust i64 all round-trip them fine. JavaScript does not.
This is why the platforms that generate them serialize them as strings. Discord’s API reference states it plainly: “Because Snowflake IDs are up to 64 bits in size (e.g. a uint64), they are always returned as strings in the HTTP API to prevent integer overflows in some languages.”
What to do
1. Send them as strings
The only fix that removes the failure mode instead of working around it. "id": "1234567890123456789" parses to an exact string in every language, and an ID is an opaque identifier — you do not do arithmetic on it. If you own the producer, this is the change to make.
Type it as string on the client too. A generated type that says id: number is the bug written down.
2. Parse with context.source
If you consume someone else’s API and it emits bare 64-bit numbers, the reviver callback now receives a third argument with the original source text, before it was turned into a double:
function reviveBigInts(key, value, context) {
if (typeof value === 'number' && context && typeof context.source === 'string') {
// Only rewrite when the literal text does not survive the round-trip.
if (/^-?\d+$/.test(context.source) && String(value) !== context.source) {
return BigInt(context.source);
}
}
return value;
}
const parsed = JSON.parse('{"id":1234567890123456789,"count":42,"ratio":0.5}', reviveBigInts);
parsed.id; // 1234567890123456789n (bigint)
parsed.count; // 42 (number)
parsed.ratio; // 0.5 (number)
The String(value) !== context.source guard is what keeps ordinary integers as numbers — only the literals that actually lost information get promoted. JSON.parse source text access is Baseline 2025, newly available; core-js polyfills it for older runtimes.
3. Serialize back with JSON.rawJSON
JSON.stringify refuses to serialize a BigInt, so the return trip needs its counterpart:
JSON.stringify({ id: 1n });
// TypeError: Do not know how to serialize a BigInt
JSON.stringify({ id: JSON.rawJSON('1234567890123456789') });
// '{"id":1234567890123456789}'
JSON.rawJSON takes JSON source text for a single primitive and splices it into the output verbatim. It rejects objects and arrays.
Caveats
BigIntdoes not silently coexist withnumber.1n + 1throws aTypeError, andJSON.stringifyon a bareBigIntthrows as shown above. Promoting IDs toBigIntis safe precisely because you should never be doing arithmetic on them — if you are, this is not the right fix.- The reviver runs on every number in the document. The round-trip guard above keeps the blast radius small, but a document with genuinely huge floats is a different problem; this technique is for integer identifiers.
- Fixing the consumer does not fix the pipeline. Any hop in front of you that parses and re-emits JSON — a gateway, a webhook relay, a log pipeline, a “pretty print this payload” step — corrupts the ID before your reviver ever sees it. Check what the bytes look like at your edge, not at the origin.
- Detection is possible without a reviver: if
String(Number(text)) !== textfor an integer literal, that literal cannot round-trip. That check is also the cheapest way to audit a captured payload. Number.MAX_SAFE_INTEGERis not where trouble starts, it is where it becomes guaranteed. Values below it are exact; the first ambiguous integer is2**53itself, which shares a double with2**53 + 1.