Posted in Blog · Reading time ~9 min
Why your JSON IDs are wrong: the BigInt precision problem
If your JSON contains integers larger than 9,007,199,254,740,991 (that's 253−1, "Number.MAX_SAFE_INTEGER" in JavaScript), JSON.parse will silently round them to the nearest 64-bit float. Twitter IDs, Discord snowflakes, MongoDB long IDs, Salesforce IDs, every database BIGINT primary key — all are at risk. This post explains the mechanism, shows the exact failure, and gives five fixes that survive a real production system.
You can reproduce the bug in two lines in any browser DevTools console:
JSON.parse('{"id": 9007199254740993}').id
// → 9007199254740992
JSON.parse('{"id": 12345678901234567}').id
// → 12345678901234568
The parser didn't fail. It rounded. There is no warning. Your === comparison against the "correct" ID will silently return false, your database lookup will return "not found", and your tests will pass because your test fixtures happen to have small IDs.
Why this happens
The JSON spec (RFC 8259) defines a number as a sequence of digits with an optional sign, fraction, and exponent. It pointedly does not specify a precision: "This specification allows implementations to set limits on the range and precision of numbers accepted." Most implementations follow the convention of double-precision IEEE 754 — a 64-bit float with 52 explicit mantissa bits plus an implicit leading 1, giving 53 bits of significand. That's the source of 253: it is exactly the largest power of two for which every smaller integer can be represented exactly.
From 253+1 upward, you get every other integer. From 254+1 upward, every fourth one. The gaps widen as the magnitude grows. The parser doesn't refuse to give you a number; it gives you the closest representable one. That's what "silent" means.
JavaScript's JSON.parse is the most commonly cited offender because JSON.parse can only produce a Number — JavaScript's built-in BigInt didn't ship until ES2020 and even now is not what JSON.parse returns. But the problem isn't JavaScript-specific. Any language whose default JSON parser deserializes "number" to a double has the same bug. Java's Jackson, .NET's System.Text.Json, Go's encoding/json with interface{}, Python's json module (the exception — it returns int with arbitrary precision, hooray) — read the docs of yours and find out.
Where it shows up in real systems
- Twitter / X IDs. Snowflake-style 63-bit integers. Twitter publishes both
id(the number) andid_str(the same value as a string) in every response and has done since 2010 — that's the historic case study. - Discord, Mastodon, every other Snowflake. Same shape, same risk.
- MongoDB
NumberLong. 64-bit. The MongoDB extended JSON format wraps these as{"$numberLong": "12345..."}specifically to round-trip safely. - Salesforce. 18-character base-62 ID. Stays a string and is safe — until someone parses it as a number for a "cleanup".
- Auto-increment
BIGINTprimary keys. A young table is fine because the IDs are small. A 5-year-old table at any decent scale is not. - Timestamps in nanoseconds. Nanoseconds since epoch overflowed
253in 2255-ish, but Unix microseconds in JavaScript (performance.now() * 1000) plus a millisecond epoch already crosses 1015 in some accounting systems. Watch for it.
Fix 1 — change the producer (the right answer)
Send the value as a string. Period. This is what Twitter does, what Stripe does for IDs, and what every well-considered API does: large integers go on the wire wrapped in quotes.
{ "id": "12345678901234567" }
Yes, you lose mathematical operations on the value. You shouldn't have been doing arithmetic on a database ID anyway.
If the producer is a third-party API you can't change, skip to fix 4.
Fix 2 — use a BigInt-aware parser on the consumer
When the JSON contains "id": 12345678901234567 (no quotes, you don't control the producer), you need a parser that hands you a BigInt for "too big to be safe" numbers.
In Node and modern browsers, the cleanest pattern is the json-bigint library or its descendants:
import JSONBig from 'json-bigint';
const parser = JSONBig({ useNativeBigInt: true });
const obj = parser.parse('{"id": 12345678901234567}');
typeof obj.id; // 'bigint'
obj.id === 12345678901234567n; // true
In a single-file context where you can't add a dependency, you can pre-process the JSON to quote big-looking numbers, parse with the normal parser, and then re-cast yourself:
function quoteBigInts(s) {
// Look for an integer literal of 16+ digits as a JSON value.
return s.replace(/(?<=[:\[,]\s*)(-?\d{16,})(?=\s*[,\]}])/g, '"$1"');
}
That regex is a fast approximation — a real BigInt parser tokenizes properly and won't false-positive on the same digits appearing inside a string. The JSON Validator here flags integers above Number.MAX_SAFE_INTEGER in its warnings panel for exactly this reason.
Fix 3 — keep the value as a string at the boundary
If your code never needs the value as a number — just as a key, an identifier, a thing to log — the simplest move is "never parse it". Hand-extract the value with a regex against the raw JSON text:
const m = body.match(/"id":\s*(-?\d+)/);
if (m) processId(m[1]);
Ugly, but for one-off scripts or background workers that only care about the ID, it sidesteps the entire problem and is faster than a parse.
Fix 4 — declare the format in your schema
If you use JSON Schema, you can tell consumers "this is a number, but parse it carefully":
{
"type": "string",
"pattern": "^-?[0-9]+$",
"description": "64-bit signed integer ID, transmitted as a string."
}
Combine this with OpenAPI's format: int64 on the same field for code generators that understand it. Generated clients in strongly-typed languages will then use Long / BigInteger / BigInt at the binding layer and not lose precision.
Fix 5 — quote the big ones at egress
The half-step between "broken" and "fix 1" is to keep your database column as BIGINT and serialize specifically the values above 253−1 as strings on the way out. Mixed types are a code smell in an API but they preserve backward compatibility:
function safeJson(v) {
if (typeof v === 'bigint' || (typeof v === 'number' && Math.abs(v) > Number.MAX_SAFE_INTEGER)) {
return v.toString();
}
return v;
}
Use it as the replacer in JSON.stringify. Old consumers that always treated the field as a number will fail on the string ones, which is at least loud.
The test that catches it
If you remember nothing else from this post, write this test against any endpoint that returns user-facing IDs:
test('round-trips a 17-digit id without loss', () => {
const id = '12345678901234567';
const stored = createRecord(id);
expect(stored.id).toBe(id); // string equality, not numeric
const fetched = getRecord(id);
expect(fetched.id).toBe(id);
});
The version that uses a number literal — const id = 12345678901234567; — has the bug encoded in the test itself and will pass against a broken implementation.
One more sneaky one: floats
Even values that fit fine in a double can be unpleasant when stringified. Money in particular:
JSON.stringify({amount: 0.1 + 0.2})
// → '{"amount":0.30000000000000004}'
This is not a JSON bug, it's a binary-floating-point bug, but JSON exposes it on the wire. The fix is the same shape as for IDs: keep money in minor units (integer cents) on the wire, or transmit as a fixed-precision string and let the consumer parse it with a decimal type. Stripe sends amounts as integer cents. So should you.
To probe a payload for affected fields, paste it into the JSON formatter and tree viewer and look for any number larger than 9,007,199,254,740,991 (253−1). The JSON validator won't flag precision loss — it's a value-domain problem, not a syntax one — so you have to spot them visually or with a JSON Schema rule requiring type: string for IDs.
If you've hit this in a more exotic place — Salesforce's odd 18-char IDs, BigQuery's NUMERIC, Avro logical types — drop a note and we'll fold the recipe into a follow-up.