Whatâs Actually Inside Your JSON Numbers? (Why Big IDs Lose Precision)
Published on May 16, 2026 by The Kestrel Tools Team ⢠9 min read
A support ticket lands in your queue: âThe dashboard shows the wrong order ID.â You pull up the API response in your browser. The ID looks fine. You paste the same response into your JSON formatter. The ID changes. Two trailing digits flipped. You didnât touch the data â the parser did.
Welcome to JSON number precision in JavaScript, where every number above 9,007,199,254,740,991 is a coin toss. The bug is older than Twitterâs snowflake IDs (which is the canonical example), but it keeps biting new code in 2026 because new APIs keep shipping with 64-bit integer IDs and old client libraries keep silently rounding them.
This post is a walkthrough of whatâs actually inside a JSON number, why JavaScript loses precision on large integers, how to detect it in two seconds, and the four real workarounds â with the trade-offs spelled out so you can pick the right one for your code.
Why does JavaScript lose precision on large JSON numbers?
Because the JSON spec doesnât define a numberâs precision â the parser does. JavaScriptâs JSON.parse decodes every numeric value into a 64-bit IEEE 754 double, which can only represent integers exactly up to 2â´â´ â 1, or 9,007,199,254,740,991. Anything larger gets rounded to the nearest representable double.
That number has a name: Number.MAX_SAFE_INTEGER. Itâs the largest integer n such that n and n + 1 are both exactly representable. Above it, the rounding is silent. Thereâs no exception, no warning, no NaN. The wrong number just shows up in your code.
You can reproduce the whole bug in a console in five seconds:
JSON.parse('{"id": 9007199254740993}');
// { id: 9007199254740992 }
JSON.parse('{"id": 9007199254740995}');
// { id: 9007199254740996 }
The input string is a perfectly valid JSON number. The output is wrong. JavaScript canât represent 9,007,199,254,740,993 â the nearest double is 9,007,199,254,740,992 â so thatâs what you get. No flag, no log line, no opportunity to recover the original value. The precision was lost the moment JSON.parse looked at the digits.
What an IEEE 754 double actually stores
A JavaScript number is 64 bits, laid out as:
- 1 sign bit
- 11 exponent bits (biased)
- 52 mantissa (significand) bits
Those 52 mantissa bits, plus an implicit leading 1, give you 53 bits of integer precision. 2â´â´ = 9,007,199,254,740,992. Below that boundary, every integer is exactly representable. Above it, the gap between adjacent doubles widens: at 2â´â´ the gap is 2 (so you lose every other integer), at 2â´â´ the gap is 4, and so on.
This is why the rounding is silent and asymmetric. The parser isnât âtruncatingâ or âclampingâ â itâs storing the closest double it can, and the input digits beyond the precision boundary simply have nowhere to live.
Different runtimes do not always behave the same way. Pythonâs json module preserves big integers exactly, because Python integers are arbitrary precision. Goâs encoding/json defaults to float64 (same problem as JS) unless you use json.Number. Rustâs serde_json parses to Number, which can hold a u64 exactly. JSON itself isnât the broken part â the JavaScript parser is. This matters when youâre debugging cross-language API issues: the value left the server fine, the wire format carried it fine, the JS parser is where it died.
Which APIs ship IDs that hit this bug?
More than youâd expect. A non-exhaustive list of real-world API IDs that exceed Number.MAX_SAFE_INTEGER:
- X (Twitter) snowflake IDs â 64-bit integers, the original cautionary tale. Twitterâs Engineering blog flagged this in 2010 and has shipped string-typed IDs (
id_str) ever since. - Discord snowflake IDs â also 64-bit, same Twitter-derived format. Discordâs API returns them as strings for exactly this reason.
- YouTube video IDs â strings (correct), but their internal
videoIdnumeric forms in some legacy endpoints exceed safe range. - Stripe charge / customer IDs â mostly opaque strings (correct), but their underlying integer counters exceed safe range and would round if exposed numerically.
- PostgreSQL
bigintprimary keys â anything past 2â´â´ in your own database. Most tables are nowhere near this; auto-incrementing tables that started in 1990 might be. - Snowflake-pattern IDs in custom APIs â if youâve used a snowflake generator (Twitterâs, Sonyâs
sonyflake, Discordâs), your IDs are 64 bits and will round in JS.
The pattern is: anything that uses a 64-bit integer namespace will eventually exceed Number.MAX_SAFE_INTEGER. Itâs only 16 digits. Modern ID generators blow past it within seconds of starting up.
How to spot precision loss in 30 seconds
You donât need a debugger. There are three quick tests, in order of speed:
1. Visual check in a JSON formatter. Paste a payload with a long integer ID into a client-side JSON Formatter and compare the input and output digits. If the formatter parses with JSON.parse and re-stringifies, the digits will change for any value above 2â´â´. (Kestrelâs JSON Formatter preserves the raw string in this case, which is itself a useful signal: when the input and reformatted versions diverge on a numeric field, youâve found a precision bug.)
2. Console one-liner. Drop into your browser DevTools and run:
Number('9007199254740993') === 9007199254740993
// false
9007199254740993 === 9007199254740992
// true
If you can compare the literal you sent against the parsed value and they disagree, thatâs the bug.
3. Roundtrip test. Take any suspect ID, and run:
const original = '9007199254740993';
const parsed = JSON.parse(`{"id": ${original}}`).id;
console.log(String(parsed) === original);
// false â precision was lost
If String(parsed) !== original, your number didnât survive the round trip. This is the diagnostic that scales: you can run it in CI against any sample API response and instantly know whether you have IDs at risk.
The four real fixes (with their trade-offs)
Once youâve confirmed the bug, you have four genuine options. There is no âjust use floats more carefullyâ â IEEE 754 isnât fixable from above.
1. Send IDs as strings (server-side fix, the right one)
The API returns "id": "9007199254740993" instead of "id": 9007199254740993. Twitter, Discord, and Stripe all do this for a reason: itâs the only solution that doesnât require every client to remember to handle big integers correctly.
Pros: Works everywhere. No client-side changes. Future-proof.
Cons: Requires server cooperation. Schema migration if youâve already shipped numeric IDs. JSON consumers that do arithmetic on IDs (rare, but it happens with sequence numbers) need a parsing step.
If youâre designing a new API and the IDs might exceed 2â´â´, ship them as strings from day one.
2. Use BigInt with a custom JSON parser (client-side fix)
JavaScriptâs BigInt type (Stage 4 since 2020, available in every browser since Safari 14 in 2020) handles arbitrary-precision integers exactly. The catch: JSON.parse doesnât return BigInt for you, because it canât decide whether a given numeric literal should be a Number or a BigInt without breaking existing code.
The workaround is a JSON parser that asks. The most widely used is json-bigint:
import JSONbig from 'json-bigint';
const parsed = JSONbig.parse('{"id": 9007199254740993}');
console.log(parsed.id); // 9007199254740993n (a BigInt)
console.log(typeof parsed.id); // 'bigint'
console.log(parsed.id.toString()); // '9007199254740993'
Pros: Server doesnât have to change. Exact precision. Works for any size integer.
Cons: BigInt doesnât mix with Number in arithmetic (bigint + number throws). Adds ~5 KB to the bundle. Most React and serialization layers donât know how to render BigInt (they call JSON.stringify, which throws on BigInt unless you provide a replacer).
Use this when you control the client but not the server, and the IDs are mostly read (not arithmetic).
3. Use a reviver to keep IDs as strings (lightweight client-side fix)
If you donât need actual integer arithmetic on the IDs (which is most cases â IDs are usually compared, displayed, or sent back as-is), the cheapest fix is a custom JSON parser that emits the digits as a string:
import { parse } from 'lossless-json';
const raw = '{"id": 9007199254740993, "count": 42}';
const data = parse(raw, null, (value) => value.isInteger ? value.value : Number(value.value));
// { id: '9007199254740993', count: 42 }
The lossless-json library parses every number into a LosslessNumber and lets you decide per-field how to materialize it. Big IDs become strings, small numbers stay numbers, you keep JSON.stringify working.
Pros: No BigInt arithmetic gotchas. Smaller than json-bigint. Easy to render in UI.
Cons: You canât do math on the IDs without converting first. Extra dependency.
Use this when IDs are essentially opaque tokens â which they almost always are in API code.
4. Server-side schema discipline + TypeScript types (preventive fix)
The long-game fix is to declare IDs as opaque string types in your schema and your TypeScript:
type OrderId = string & { readonly __brand: 'OrderId' };
interface Order {
id: OrderId;
customerId: CustomerId;
total: number; // money is a different problem (also IEEE 754, also lossy)
}
This doesnât fix the bug at runtime, but it prevents the entire class of bug from being introduced in the first place. New developers canât accidentally type an ID as number and ship a precision bug â the type system rejects it.
Combine this with a server that emits IDs as strings, and the bug disappears at the boundary.
Common questions, answered concisely
Does this affect floating-point numbers too? Yes, even more so. 0.1 + 0.2 === 0.3 is false in JavaScript for the same reason. But for IDs, the integer boundary at 2â´â´ is the hard wall most people hit first.
Can I just use Number.isSafeInteger(value) to detect this? Only if you check before parsing, which you canât, because JSON.parse has already rounded by the time you see the value. Use the roundtrip test instead.
Will TC39 ever fix JSON.parse? Thereâs been a proposal (JSON.parse source text access, Stage 3 since 2023) that would let parsers see the original digits and decide what to do, which finally enables clean BigInt parsing without a third-party library. Itâs not Baseline yet in 2026 â wait until it lands across all browsers before relying on it.
Is parseInt or Number() any better? No. parseInt('9007199254740993') returns 9007199254740992 for the same reason. They all eventually go through IEEE 754 doubles.
Does this happen in fetch().then(r => r.json())? Yes. Response.json() calls JSON.parse internally. Same bug, same fix.
Verifying it in your own JSON
The single fastest sanity check: open a client-side JSON Formatter and paste this:
{
"safe": 9007199254740991,
"boundary": 9007199254740992,
"unsafe_a": 9007199254740993,
"unsafe_b": 9007199254740995,
"snowflake": 1798492649231581184
}
A precision-aware formatter shows you which fields are above Number.MAX_SAFE_INTEGER and warns when JSON.parse would round them. A naive one silently re-emits the rounded values â and now youâve reproduced the original bug, just at one extra level of indirection. The diagnostic is the same as the production bug. Thatâs the point.
The takeaway
JSON number precision in JavaScript isnât broken â itâs specified. JSON.parse decodes every number into an IEEE 754 double, which gives you 53 bits of integer precision and silent rounding above 2â´â´. Any API ID thatâs a 64-bit integer (snowflake IDs, big database keys, Discord/Twitter IDs) will eventually exceed that boundary and round.
Four fixes work:
- Send IDs as strings server-side. The right answer if you control the server.
BigInt+json-bigint. When you need arithmetic on big integers and only control the client.lossless-jsonreviver. When IDs are opaque tokens (the usual case) and you want minimal overhead.- TypeScript opaque string types. The long-game prevention.
The diagnostic is a one-liner: String(JSON.parse('{"id":' + n + '}').id) === n. If thatâs ever false, you have IDs at risk.
If you want to see the rounding happen in front of you, paste a payload with a 19-digit ID into Kestrel Toolsâ JSON Formatter â it runs entirely client-side, preserves the raw digits, and surfaces the precision-loss case so you can spot it before it ships.