---
title: "\"Your 19-digit ID is a different number after JSON.parse\" "
published: false
description: "\"RFC 8259 puts no size limit on numbers. JavaScript has exactly one numeric type. What that costs, measured on Node 22.\" "
tags: javascript, json, webdev, api
canonical_url: https://www.shoulin.shop/guides/why-json-numbers-lose-precision
---
A support ticket I keep seeing, in various disguises: "the ID changed after we formatted the JSON." It didn't change because of the formatter. It changed the moment the document was parsed, before any tool could intervene.
Here is the whole failure in one line:
JSON.parse('{"id":9007199254740993}').id
// 9007199254740992
Nothing was malformed. The container was too small.
The spec allows it; the runtime does not
RFC 8259 does not put a size limit on numbers. It explicitly permits implementations to set limits on range and precision, and it warns that integers outside ±(2^53 − 1) "indicate potential interoperability problems."
That sentence is the entire story. The format happily carries a 20-digit integer. The runtime almost everyone parses it with cannot hold one, because JavaScript has exactly one numeric type: a 64-bit float. Every number literal in a JSON document becomes a double before your code sees it.
So the document is valid, the parser is correct, and the value is wrong.
Where the boundary actually is
Number.MAX_SAFE_INTEGER is 9007199254740991, which is 2^53 − 1. An integer at or below that is represented exactly.
The next integer, 9007199254740992, is 2^53 and is still exact — powers of two always are. But 9007199254740993 does not exist as a double. It rounds down to 9007199254740992, and so does every odd integer after it, until the next power of two where the spacing widens again.
This is why the bug survives code review. An 18-digit snowflake ID sits comfortably inside the range of a 64-bit integer and comfortably outside the range of a double. The two ranges overlap just enough for everyone to assume they're the same range.
What a round trip actually changes
I ran the same document through JSON.parse and then JSON.stringify — which is exactly what "format" means — and recorded every difference.
| Input | After round trip | Why |
|---|---|---|
{"id":9007199254740993} |
{"id": 9007199254740992} |
Numbers become IEEE-754 doubles. Integers above 2^53 lose their last digits. |
{"b":1,"2":2,"1":3} |
{"1": 3, "2": 2, "b": 1} |
Keys that look like array indices are enumerated first, ascending. That is a JavaScript object rule, not a JSON one. |
{"a":1,"a":2} |
{"a": 2} |
Duplicate member names collapse silently: the last value wins and the others are gone. |
{"x":1.0,"y":1e3,"z":-0} |
{"x": 1, "y": 1000, "z": 0} |
Numbers are re-printed in their shortest round-trippable form. |
"\u00e9\/" |
"é/" |
Escapes are decoded, then only the ones that must be escaped are written back. |
Only the first row is real information loss. The other four are normalisation: the bytes differ, the meaning does not.
That distinction matters more than it looks. If you diff two documents that have both been through a formatter, you will get a report of changes no human made — reordered keys, 1.0 becoming 1, an escaped slash becoming a plain one. I built a JSON diff tool around this, and the "ignore key order" toggle exists precisely because of row two.
Object key order is not insertion order
Row two is the one people rarely expect. A JavaScript object enumerates integer-like keys first, in ascending numeric order, then the remaining string keys in insertion order.
{"b":1,"2":2,"1":3} therefore comes back as {"1":3,"2":2,"b":1} — and no, that is not a bug in the formatter. JSON itself defines no order at all; it is a set of name/value pairs. Any consumer depending on member order was relying on undefined behaviour from the start.
If order is part of your payload, put it in the data: an array of objects with an explicit position field, not the order the bytes happened to arrive in.
Three ways to keep big integers intact
In order of how much you should prefer them.
1. Make the field a string in the API. This is what large platforms do. {"id":"9007199254740993"} is unambiguous, survives every parser on every platform, and costs one pair of quotes. Nobody downstream has to remember a rule.
2. Intercept the parse with a reviver. A reviver can inspect each value before your code sees it. Recent V8 engines pass a third argument that exposes the raw literal, which is what makes this reliable instead of guesswork:
JSON.parse(text, (key, value, context) => {
// context.source is the literal exactly as written in the document.
const raw = context?.source;
if (typeof value === "number" && !Number.isSafeInteger(value)
&& raw && /^-?\d+$/.test(raw)) {
return BigInt(raw);
}
return value;
});
I verified this on Node 22.22.2 — context.source really does hold "9007199254740993" before the double conversion wins. But feature-detect it rather than trusting it: if context is undefined, the engine predates the feature and the digits are already gone. In that case fall back to option 1 or 3.
3. Use a lossless parser. Libraries like lossless-json keep the original text of each number and hand you a wrapper type. They work everywhere, at the cost of a dependency and a value type nobody on your team has seen before.
If you hash or sign JSON
A round trip is not byte-stable, so never re-serialise a payload that was signed.
Two documents can be semantically identical and hash differently because of whitespace, key order, or number formatting — 1.0 and 1 are the same float and different bytes. A payload that went through JSON.parse and back may no longer match its own signature, and the failure looks like a corrupted message rather than a formatting difference.
Sign the exact bytes you send: serialise once, hash that string, transmit the same string. On the receiving side, verify before parsing, not after.
The short version
- JSON allows numbers of any length; JavaScript has one numeric type, and it's a float.
- The safe range is ±(2^53 − 1). Snowflake IDs sit just outside it, which is why this bites production systems and not tutorials.
- Strings for IDs. Always.
- A formatter can't fix this, because the damage happens inside the parse. Any tool claiming to preserve
9007199254740993as a number is lying about what the runtime holds afterwards.
The full version, with the same measurements and a few more cases, is at shoulin.shop/guides/why-json-numbers-lose-precision. If you want to watch the loss happen to your own payload, the formatter runs entirely in the tab — no upload, no account, and the Network tab will show you nothing leaving.
Top comments (1)
Python 不会丢,因为 json 模块默认解析成任意精度 int;Go 会丢,和 V8 一样」