A bug that doesn't throw an error
Precision loss in JSON numbers is one of the few JSON bugs that produces no error, no warning, and no stack trace — just a number that's silently *wrong*, usually discovered only when a lookup by ID mysteriously fails. It's worth understanding exactly why, because the fix is a convention, not a language feature.
Where the precision actually goes
The JSON spec places no limit on how large a number can be — {"id": 99999999999999999999} is perfectly valid JSON text. The problem is entirely on the *parsing* side: JavaScript's JSON.parse deserializes every JSON number into the number type, which is a 64-bit IEEE 754 double — the same type used for 3.14, -2, and everything else numeric in JS. A double can represent integers exactly only up to Number.MAX_SAFE_INTEGER — 2^53 - 1, or 9,007,199,254,740,991. Past that, adjacent integers start mapping to the *same* double.
JSON.parse('{"id": 9007199254740993}');
// => { id: 9007199254740992 } — off by one, silentlyNote this isn't unique to JSON.parse — 9007199254740993 typed directly as a JS numeric literal has exactly the same problem, since it's the number type itself that can't represent it, not the JSON parser specifically. But it hits JSON far more often in practice, because JSON numbers routinely come from systems that *don't* have this limitation — Postgres bigint, Java long, Go int64, Twitter/X-style Snowflake IDs — all of which comfortably exceed 2^53 as a matter of routine, not an edge case.
Where this actually bites
64-bit database primary keys. A Postgres bigint column, or any auto-incrementing ID on a busy table, will eventually exceed 2^53. An API that serializes that ID as a bare JSON number will corrupt it for every JavaScript client the moment it crosses that threshold — and because it happens silently, the bug often ships and sits unnoticed until IDs get large enough, which can be months or years into a table's life.
Snowflake-style distributed IDs. IDs generated by a timestamp+machine-id+sequence scheme (Twitter/X's original Snowflake, Discord's, Instagram's) are 64-bit integers by design and are typically already well past 2^53 on day one — this is precisely why X's API returns both an id (numeric, truncated for legacy compatibility) and an id_str (string, exact) for every object.
Financial and scientific data. Any value where losing the last few digits of precision is a correctness problem, not just a cosmetic one — pass it as a string, or scale it into an integer-safe range (e.g., store cents instead of dollars, and even then, watch the upper bound).
The fix: strings, not bare numbers
The universal convention is to serialize anything that might exceed 2^53 as a JSON string, not a bare number:
{
"id": "9223372036854775807",
"id_numeric_truncated": 9223372036854776000
}A string is exact — JSON strings have no numeric precision limit, so any 64-bit (or larger) integer round-trips perfectly as text. The tradeoff is that the consumer has to know to treat that field as an opaque identifier, not something to do arithmetic on directly — which is almost always the correct mental model for an ID anyway.
Handling it if you can't change the API
If you're stuck consuming an API that returns large numbers as bare JSON numbers (already lossy on the wire, nothing you can do after the fact), JavaScript's native BigInt can at least prevent *further* precision loss during your own processing — but only if you avoid JSON.parse's default number handling entirely, since the damage from the original serialization already happened before your code ever runs:
// JSON.parse's optional "reviver" can intercept and reparse big numbers as BigInt —
// but only if the source JSON already preserved the value as a string.
const data = JSON.parse(text, (key, value) => {
if (typeof value === "string" && /^\d{16,}$/.test(value)) {
return BigInt(value);
}
return value;
});This only works if the *source* JSON encoded the number as a string in the first place — if the sender already serialized it as a bare number, the precision is already gone by the time your parser sees it, and no amount of clever parsing on your end recovers it. There's no way around fixing this except at the source.