The IDs That Came Back Wrong: JavaScript's 2^53 Integer Ceiling
A handful of users reported that clicking a notification took them to the wrong record. The IDs were off by one or two at the very end, always on the biggest accounts. The backend was sending the correct 64-bit ID every time. Our frontend was quietly rounding it the instant it parsed the JSON, because in JavaScript every number is a float.
The bug reports were rare and confusing. A user would click an item in a list, land on a detail page, and see the wrong record. Not random garbage, just a neighbor: the order next to the one they wanted, or an account a couple of IDs away. It only happened to our largest customers, the ones who had been around long enough to have very high primary keys. Every layer we checked was innocent. The database had the right row. The API response, captured straight off the wire, contained the right ID. By the time the React code read that ID, it was wrong.
The culprit was the most fundamental thing about the language, and the easiest to forget: JavaScript has exactly one number type, and it is a 64-bit float. There is no integer type behind the scenes. Our IDs were 64-bit integers from a Postgres bigint column, and a 64-bit float cannot represent every 64-bit integer. Past a certain size, parsing the number throws away the low bits, and the ID you get back is the nearest value the float can hold.
Where the precision actually runs out
A JavaScript number is an IEEE 754 double. It has 52 bits of stored mantissa, which gives 53 bits of integer precision once you count the implicit leading bit. That means every integer up to 2^53 is representable exactly, and the language even names that boundary: Number.MAX_SAFE_INTEGER is 9007199254740991, which is 2^53 minus 1. Go one past it and the gaps begin.
Number.MAX_SAFE_INTEGER; // 9007199254740991 (2^53 - 1)
9007199254740993 === 9007199254740992; // true (!) the odd number can't be stored
9007199254740993; // 9007199254740992 it rounds on the way in
Above 2^53 the doubles can only land on even numbers, then only multiples of four, and so on, the spacing doubling at each power of two. A 64-bit integer ID can be far larger than 2^53. Snowflake-style IDs, anything seeded with a timestamp, and long-lived auto-increment keys at scale all cross the line. Once they do, the number you read is silently snapped to the nearest representable double. No exception, no NaN, just a slightly wrong integer that looks completely normal.
The corruption happens in JSON.parse, not in your code
The part that made this so hard to find: our code never did arithmetic on the ID. We treated it as an opaque identifier, exactly as you are supposed to. It did not matter. The damage was done by JSON.parse, before a single line of our logic ran.
const wire = '{"id": 9007199254740993, "name": "Acme"}';
const obj = JSON.parse(wire);
obj.id; // 9007199254740992 already wrong
String(obj.id); // "9007199254740992" stringifying later does not help
The standard JSON parser reads a numeric literal straight into a double, and the rounding is baked in at that moment. Converting it to a string afterward just stringifies the already-broken value. This is why inspecting the raw HTTP response showed the correct digits while the parsed object did not: the bytes on the wire were fine, and JSON.parse is where they stopped being fine.
The fix is to never let the ID be a number
The durable solution is to keep large IDs as strings the entire way through the system, so no float ever touches them. The cleanest place to enforce that is the API boundary: have the backend serialize 64-bit IDs as JSON strings, not JSON numbers.
// instead of {"id": 9007199254740993}
// send {"id": "9007199254740993"}
A quoted value goes through JSON.parse as a string, byte for byte, with no numeric conversion and nothing to round. The frontend treats it as the opaque token it always was, puts it in URLs, sends it back in requests, compares it with ===, and never does math on it, which is the whole point of an identifier. Most serialization libraries can be told to emit specific integer types as strings, and many gRPC and protobuf JSON mappings already encode 64-bit fields this way by default for exactly this reason.
If you cannot change the wire format, you have to intercept the JSON before the numbers are parsed. A reviver alone is too late, because the value is already a rounded double by the time the reviver sees it. You need a parser that can hand you large integers as BigInt or string, for example the json-bigint family of libraries, applied to the raw response text.
// too late: the number is already rounded before the reviver runs
JSON.parse(text, (k, v) => v); // does not help
// works: parse with a library that keeps big integers intact
import JSONbig from "json-bigint";
JSONbig({ storeAsString: true }).parse(text); // id stays a faithful string
BigInt exists, but it is not a free swap
Modern JavaScript does have a true arbitrary-precision integer type, BigInt, written with an n suffix (10n). It represents any 64-bit integer exactly, so it is the right type if you genuinely need to do integer math on these values. But it is not a drop-in replacement for a number. You cannot mix BigInt and Number in the same expression without an explicit conversion, JSON.stringify throws on a BigInt unless you give it a replacer, and a lot of library code assumes plain numbers. For something you only ever pass around and compare, a string is simpler and avoids all of that friction. Reach for BigInt when the value is a quantity you compute with, and a string when it is an identifier you carry.
Why it hides for so long
This class of bug is nasty precisely because it is invisible in every environment where it matters least. In development and in tests your seed data has small IDs, well under 2^53, so everything is exact and every test passes. It only surfaces in production, only for the accounts with the highest IDs, and it fails quietly by landing on a real neighboring record instead of throwing. Nothing in the stack trace points at floating point. The way to catch it before users do is to seed at least one test fixture with an ID above Number.MAX_SAFE_INTEGER and assert it survives a full round trip through your serialization, your client, and back. If that fixture comes back changed, you have found it on your terms instead of theirs.
Rules of thumb
- Every JavaScript number is a 64-bit float. Integers are exact only up to
Number.MAX_SAFE_INTEGER, which is 2^53 minus 1. Above that, values silently round to the nearest representable double. - A 64-bit integer ID (bigint keys, snowflake IDs, timestamp-seeded IDs) routinely exceeds 2^53, so it cannot survive as a JavaScript number.
- The corruption happens inside
JSON.parse, before your code runs. Stringifying the parsed value afterward keeps the already-wrong number. - The robust fix is to send large IDs as JSON strings from the backend, so no float ever touches them and the frontend treats them as opaque tokens.
- If you cannot change the wire format, parse the raw text with a big-integer-aware parser. A standard
JSON.parsereviver runs too late to save the value. - Use
BigIntwhen you need exact integer math, a string when it is an identifier you only pass around and compare. - Seed a test fixture with an ID above 2^53 and assert it round-trips unchanged. Small dev IDs hide this bug until a large production account hits it.