Round-trip this through JSON.parse and you do not get back what you put in:
JSON.parse('{"v":9007199254740993}').v
// 9007199254740992No error, no warning. The last digit changed. If that value was an order ID, two different orders now have the same one.
The spec and the parsers disagree
RFC 8259 places no limit on the magnitude or precision of a JSON number. The grammar is just an optional minus, digits, an optional fraction and an optional exponent. By the letter of the spec, 9007199254740993 is a perfectly good JSON number and a conforming parser should preserve it.
The spec also says, in as many words, that implementations will vary and that interoperability is best served by staying within IEEE 754 double precision. Which is a polite way of saying: everyone uses doubles, so you should assume doubles.
A double has 53 bits of mantissa. The largest integer it can represent exactly is 2^53 − 1 = 9,007,199,254,740,991 — JavaScript exposes it as Number.MAX_SAFE_INTEGER. Above that, consecutive integers stop being distinguishable: 9007199254740992 and 9007199254740993 are the same double, and the parser hands you the even one.
What actually comes back
Measured, not remembered — this is JSON.parse followed by JSON.stringify in Node 22:
Where it bites in practice
- Snowflake IDs. Twitter/X, Discord and anything using the same scheme emit 64-bit integer IDs. Every one of them is past 2^53. This is why those APIs also return an
id_str. - Database bigints. A Postgres
bigintprimary key crosses 2^53 at around 9 quadrillion — fine for a sequence, not fine if the key is a hash or a timestamp-based ID. - Money.
19.99 * 100is1998.9999999999998, not1999. Binary floating point cannot represent0.1exactly, so0.1 + 0.2 === 0.3isfalse. - Nanosecond timestamps. Unix nanoseconds passed 2^53 in 1970 + ~104 days. Any ns-precision timestamp is already unsafe.
- Checksums and hashes stored as integers rather than hex strings.
Three fixes, in order of preference
1. Send it as a string
The right answer almost every time, and what the large APIs settled on:
{ "id": "1234567890123456789" }A string round-trips exactly through every parser in every language with no special handling. The objection — "but it is a number" — does not survive contact with the question *what arithmetic do you do on it?* For an identifier the answer is none. It is a label that happens to be spelled with digits, and labels are strings.
2. BigInt, with care
JavaScript's BigInt holds arbitrary-precision integers, and BigInt('1234567890123456789') round-trips exactly. Two sharp edges: JSON.parse never produces one, so you need a reviver or a parsing library that supports it, and JSON.stringify throws a `TypeError` on a BigInt rather than serialising it. You have to handle both directions yourself.
3. A lossless parser
Libraries such as json-bigint and lossless-json keep the original text of each number and hand back an exact representation. Useful when you cannot change the producer — a third-party API you do not control. The cost is that the values are no longer plain numbers, so everything downstream has to cope.
For money, do not use floats at all
Separate problem, same root cause. Store currency as an integer number of minor units (1999 for £19.99) or as a decimal string, and use a decimal library for arithmetic. Floats accumulate error; over a ledger of any size that error becomes a reconciliation failure that is miserable to trace.
How to tell whether you have this bug
Paste the response into the JSON validator or the formatter and compare the output with the raw text. Both parse the document and write it back, so any number that cannot survive a round trip changes visibly in the output. An ID ending in ...800 or ...000 that did not end that way in the original is the tell.
A quicker heuristic while reading a payload: count the digits. Sixteen or more in an integer, and it is worth checking. Everything happens in your browser, so you can safely paste a real production response to find out.