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));
Output:
9007199254740992
{"orderId":9007199254740992,"amount":1.5}
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
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
Further up the range the gaps get wider, so the error gets bigger:
JSON.parse('{"big": 12345678901234567890}').big // 12345678901234567000
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}'
| 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
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]}');
Output in Node 23:
[
{ key: 'id', source: '9007199254740993', parsed: 9007199254740992 },
{
key: '0',
source: '12345678901234567890',
parsed: 12345678901234567000
}
]
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
JSON.stringify refuses to write a BigInt:
TypeError: Do not know how to serialize a BigInt
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}'
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 }
A tool built on the standard functions gives back:
{"a":9007199254740992,"b":12345678901234567000,"c":1.5,"d":null}
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
- MDN: JSON.parse()
- MDN: Number.MAX_SAFE_INTEGER
- MDN: JSON.rawJSON()
- TC39: JSON.parse source text access
Written with AI assistance. Every code sample was run in Node 23, and the outputs shown are the actual results.
Top comments (1)
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?