DEV Community

Anjaneyulu
Anjaneyulu

Posted on Originally published at curlyjson.com

Why JavaScript Silently Changes Large JSON Numbers

JavaScript can silently change large integers when it parses perfectly valid JSON. That matters for database IDs, API responses, and the tools you use to look at them. Run this in Node or a browser console:

const order = JSON.parse('{"orderId": 9007199254740993, "amount": 1.50}');
console.log(order.orderId);
console.log(JSON.stringify(order));
Enter fullscreen mode Exit fullscreen mode

Output:

9007199254740992
{"orderId":9007199254740992,"amount":1.5}
Enter fullscreen mode Exit fullscreen mode

The ID is now off by one. There's no exception and no warning, and the result is still valid JSON. If you pass that ID to the next request, you're asking about a different order.

Where the digit goes

JSON's grammar puts no limit on the size of a number. JSON.parse does: it turns every number into a JavaScript Number, which is an IEEE 754 double. A double has a 53-bit significand, so every integer up to 2^53 − 1 is stored exactly:

Number.MAX_SAFE_INTEGER // 9007199254740991
Enter fullscreen mode Exit fullscreen mode

Above that, only some integers fit. Between 2^53 and 2^54, a double can only hold even integers. 9007199254740993 is odd, so it's rounded to a neighbour, 9007199254740992. The same thing happens to the literal in your source code, before any JSON is involved:

9007199254740993 === 9007199254740992 // true
Enter fullscreen mode Exit fullscreen mode

Further up the range the gaps get wider, so the error gets bigger:

JSON.parse('{"big": 12345678901234567890}').big // 12345678901234567000
Enter fullscreen mode Exit fullscreen mode

Not every large integer changes: 9007199254740992 is stored exactly. The trouble is that you can't tell from the parsed value whether it was rounded.

Two related changes come from the same step. 1.50 comes back as 1.5: the value is the same, but once it's a Number there's no record of the trailing zero, so JSON.stringify can't reproduce what you sent. And a number too large for a double becomes Infinity, which JSON can't represent, so it's written out as null:

JSON.stringify(JSON.parse('{"d": 1e400}')) // '{"d":null}'
Enter fullscreen mode Exit fullscreen mode
JSON input After JSON.parse + JSON.stringify What changed
9007199254740993 9007199254740992 Integer rounded
12345678901234567890 12345678901234567000 Integer rounded
1.50 1.5 Trailing zero dropped, value unchanged
1e400 null Too large: parsed as Infinity, which JSON can't represent
"9007199254740993" "9007199254740993" Nothing: strings are kept as written

JSON itself doesn't limit number size. RFC 8259 lets implementations set their own limits, and notes that integers between −(2^53 − 1) and 2^53 − 1 are the ones every implementation using double-precision numbers will agree on. The rounding comes from JavaScript's Number, not from JSON.

What's usually affected

  • 64-bit integer IDs (BIGINT) once their values pass 2^53, for example IDs generated from timestamps or random bits rather than a small counter.
  • Snowflake-style IDs. X (Twitter) and Discord both use them, and both are far above 2^53.
  • Numbers produced by backends with native 64-bit integers. The server serializes them correctly; the precision is lost only when JavaScript parses them.

Some APIs work around it. Discord's API reference says snowflakes are always returned as strings in the HTTP API, to avoid integer overflow in some languages. Twitter's API returned every ID twice, as the number id and the string id_str, and its documentation told JavaScript developers to use id_str.

Checking whether a value may have been rounded

After parsing, Number.isSafeInteger tells you whether an integer is inside the exact range:

Number.isSafeInteger(order.orderId) // false
Number.isSafeInteger(9007199254740991) // true
Enter fullscreen mode Exit fullscreen mode

false means the value may have been rounded, not that it was: 9007199254740992 is itself representable. To know for sure you need the original text, and newer runtimes give it to you. The reviver function passed to JSON.parse now receives a third argument, context, whose source property is the number exactly as it was written. This comes from the TC39 proposal JSON.parse source text access, which is at Stage 4.

This function finds integers outside JavaScript's safe integer range and shows their original text alongside the parsed value:

function unsafeIntegers(text) {
  const found = [];
  JSON.parse(text, (key, value, context) => {
    if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value))
      found.push({ key, source: context.source, parsed: value });
    return value;
  });
  return found;
}

unsafeIntegers('{"id": 9007199254740993, "user": {"id": 42}, "ids": [12345678901234567890]}');
Enter fullscreen mode Exit fullscreen mode

Output in Node 23:

[
  { key: 'id', source: '9007199254740993', parsed: 9007199254740992 },
  {
    key: '0',
    source: '12345678901234567890',
    parsed: 12345678901234567000
  }
]
Enter fullscreen mode Exit fullscreen mode

The second key is '0' because the value is the first element of the ids array. In runtimes that don't support the proposal, context is undefined, so check your target environments before relying on it.

Keeping large integers exact

Send them as strings. If you control the API, "orderId": "9007199254740993" is the most robust fix, because it works in every client and language. This is what Discord does.

Convert them in a reviver. This approach requires a runtime that supports JSON.parse source text access. With it, you can turn a specific field into a BigInt:

const text = '{"orderId": 9007199254740993, "amount": 1.50}';
const exact = JSON.parse(text, (key, value, context) =>
  key === 'orderId' ? BigInt(context.source) : value);

exact.orderId // 9007199254740993n
Enter fullscreen mode Exit fullscreen mode

JSON.stringify refuses to write a BigInt:

TypeError: Do not know how to serialize a BigInt
Enter fullscreen mode Exit fullscreen mode

The same proposal adds JSON.rawJSON, which lets a replacer emit the digits unchanged:

JSON.stringify(exact, (key, value) =>
  typeof value === 'bigint' ? JSON.rawJSON(value.toString()) : value);
// '{"orderId":9007199254740993,"amount":1.5}'
Enter fullscreen mode Exit fullscreen mode

The ID survives the round trip and amount still loses its trailing zero, because the reviver only touched orderId. MDN lists JSON.rawJSON as Baseline 2025, so it's available in current browsers but not in older ones.

Use a parser built for it. Libraries such as json-bigint ("JSON.parse with bigints support") and lossless-json ("Parse JSON without risk of losing numeric information") parse numbers without going through Number. Check how each one represents numbers before choosing; their APIs differ.

A quick test for JSON tools

Formatters, viewers and diff tools often call JSON.parse and JSON.stringify internally. To see whether one does, paste this:

{ "a": 9007199254740993, "b": 12345678901234567890, "c": 1.50, "d": 1e400 }
Enter fullscreen mode Exit fullscreen mode

A tool built on the standard functions gives back:

{"a":9007199254740992,"b":12345678901234567000,"c":1.5,"d":null}
Enter fullscreen mode Exit fullscreen mode

If the numeric text survives unchanged, that's a good sign the tool preserves number precision for this test. It doesn't guarantee every operation is lossless, so test the transformations you actually use.

References


Written with AI assistance. Every code sample was run in Node 23, and the outputs shown are the actual results.

Top comments (1)

Collapse
 
ethanmoorenotes profile image
Ethan Moore •

The Twitter id / id_str example is a good reminder that the server is doing nothing wrong here, the loss only happens at JSON.parse. One thing that has saved me is making the API contract say that any 64-bit ID is a string, and then adding a contract test that fails if a number above Number.MAX_SAFE_INTEGER shows up in a response. When you can't change the backend, a reviver that checks the raw text (the context argument in newer engines) also works. Have you tried that approach in production, or do you stick with strings?