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 videoId numeric 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 bigint primary 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-json reviver. 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.