Everyone who has met this bug has the same sentence for it: JavaScript numbers cannot hold 64-bit integers, so an ID loses its last digits. It is true, and it is also the whole of what most write-ups say. It does not tell you which of your IDs are actually at risk, how often, or what the damage looks like — and the answers are more uneven than “always” suggests. So we measured it.
What the standard promises
RFC 8259, the JSON standard, is explicit that this is a known limit. Section 6 says that
“good interoperability can be achieved by implementations that expect no more
precision or range than” IEEE 754 double-precision numbers provide, and that
integers in the range −(253)+1 to (253)−1 are
interoperable — implementations will agree exactly on their values. That is 9,007,199,254,740,991.
Above it, the standard promises nothing, and a browser's JSON.parse stores
every number as a double.
How we measured
We generated seven sets of IDs, wrote each as a JSON array of plain numbers, and parsed it
with JSON.parse in Chrome 153. For every ID we compared the number that came
out with the number that went in, as exact integers, and counted how many changed and how
many distinct IDs the array held before and after. We counted the same thing in Python,
whose float is a separate implementation of the same IEEE 754 double, and the
two counts agreed in all seven runs.
The snowflake sets follow the layout Twitter made popular and Discord and many others copied: 41 bits of milliseconds since an epoch (here 4 November 2010), 10 bits for the data centre and worker, and a 12-bit sequence number that counts up when the same worker issues more than one ID in the same millisecond. Timestamps fall on 26 September 2026, and each ID was issued at a random millisecond, the way independent requests arrive. The other sets are uniformly random 63-bit numbers and plain counters.
What came out
| IDs | Count | Changed by JSON.parse | Distinct values lost |
|---|---|---|---|
| Snowflake, 1 worker, 100 per second | 10,000 | 497 (5.0%) | 497 |
| Snowflake, 1 worker, 1,000 per second | 10,000 | 3,711 (37.1%) | 3,711 |
| Snowflake, 1 worker, 5,000 per second | 50,000 | 40,079 (80.2%) | 40,079 |
| Snowflake, 10 workers, 1,000 per second each | 100,000 | 36,662 (36.7%) | 36,662 |
| Random 63-bit numbers | 100,000 | 99,413 (99.4%) | 0 |
| Counter starting at 1015 | 100,000 | 0 (0%) | 0 |
| Counter starting at 253 | 100,000 | 50,000 (50%) | 49,999 |
Three things are worth reading out of that table, because none of them is what the usual warning suggests.
Quiet snowflake services mostly survive, busy ones do not
At 100 IDs a second on one worker, 95% of the IDs came through untouched. It looks like the bug does not exist — which is exactly why it goes unnoticed in testing. The reason is arithmetic. These IDs are about 2.1×1018, which sits between 260 and 261, where doubles are spaced 256 apart. An ID survives only if it is a multiple of 256. The low 22 bits of a snowflake hold the worker and the sequence; a worker number by itself is a multiple of 4,096, and a sequence of zero adds nothing, so the first ID a worker issues in any millisecond is exact. Every further ID in the same millisecond has a small nonzero sequence and is rounded.
So the share of IDs that break is the share that are not the first of their millisecond, and that depends on load: 5% at 100 a second, 37% at 1,000, 80% at 5,000. A staging environment with a handful of requests will never show it. Production at a busy hour will.
Every changed ID becomes a copy of another one
The last column is the one that matters most. In every snowflake run, the number of distinct values lost equals the number of IDs changed. The 10,000 IDs at 1,000 a second came out as only 6,289 different numbers. Two IDs a few units apart round to the same multiple of 256, so the ID is not merely wrong; it is identical to a different record's ID. Used as a key in a map, a cache or a set, the two records become one, and nothing throws an error.
Random IDs are wrong but not duplicated
Uniformly random 63-bit IDs almost all change (99.4%; the rest happen to have enough trailing zero bits), but not one pair merged in 100,000. Random values are far apart compared with the rounding step, so an altered ID is usually a number nobody issued. That is an inference from the result, not something we tested: it suggests a lookup would fail rather than quietly return another record. Either way the ID is no longer the one you were sent.
Counters are fine, for a very long time
A counter starting at 1015 lost nothing in 100,000 IDs, because every value is below 253. The trouble starts at that boundary: from 253 onwards half the counter values were changed and 49,999 of 100,000 collapsed into a neighbour. Reaching 253 from zero at 1,000 IDs a second takes about 285,000 years, and at a million a second about 285. A database sequence that began at 1 is not the problem; one that was seeded with a large offset, or made from a timestamp, can be.
What to do about it
- Send the ID as a string. A JSON string is never rounded, and the cost is a pair of quotation marks. It is the fix that works in every language and every version of every browser.
- Read it as a BigInt where you control the code. In Chrome 153 the
reviver of
JSON.parsereceives the source text of each number, soJSON.parse(text, (k, v, ctx) => Number.isInteger(v) && !Number.isSafeInteger(v) ? BigInt(ctx.source) : v)returned the ID1790380800000123456as a BigInt with every digit. We tested it in Chrome only. - Check a payload before you trust it. Paste it into the JSON formatter: it lists every number a JavaScript program could not hold exactly, with the value JavaScript would store, and it keeps the original digits in its own output instead of rounding them.
Limits of this measurement
The IDs are simulated. We used the Twitter-style layout with our own generator, workers numbered 0 to 9 and sequence numbers that restart each millisecond; a real service with a different layout, a different epoch or a generator that spreads its sequence differently will land on different percentages, though not on a different mechanism. Requests were spread at random, which is the friendliest assumption for a busy service, since real traffic comes in bursts. Only Chrome 153 was tested, on 26 September 2026. The Python cross-check confirms the counting, not the layout. The raw results are in the fixtures of our test suite, and the script that produced them reruns in a few seconds.