~/blog

JSON.parse silently corrupts 64-bit IDs

published

#json#javascript#apis

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 literalWhat JSON.parse returnsOff by
900719925474099390071992547409921
1234567890123456789123456789012345680011
1234567890123456789012345678901234567000890

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

References