No exception, no warning, no failing test. The value simply arrives slightly wrong, and the record it points at does not exist.
One number type, 53 usable bits
JavaScript has a single number type, and it is IEEE 754 binary64: one sign bit, eleven exponent bits, fifty-two stored significand bits plus an implicit leading one. That gives 53 bits of precision, so every integer up to 253 has an exact representation and nothing above it does.
Number.MAX_SAFE_INTEGER is 9007199254740991, and the name is precise: above it, integers stop being distinct. 9007199254740992 and 9007199254740993 are the same double. The spacing then doubles at every power of two, so around 1018 the gap between representable integers is 256.
$ node -e 'console.log(JSON.parse("{\"id\": 9007199254740993}").id)' 9007199254740992
Meanwhile a 64-bit signed integer runs to 9223372036854775807, nineteen digits. Everything that produces one collides with the limit:
- Twitter and Discord snowflake IDs
bigserialandbigintprimary keys in Postgres- Snowflake and BigQuery INT64 columns
- Unix timestamps in nanoseconds, which passed 253 in 1970 plus about 104 days
- Anything hashed into 64 bits and stored as a number
Sixteen digits is the safe boundary in practice. Nineteen-digit identifiers arriving as JSON numbers are already broken; they just have not been noticed yet.
Why it fails silently
Compare the two failure modes. A string that does not parse gives you a SyntaxError with a position, and our JSON formatter will point at the line and column. A number that does not fit gives you a number. It is the right type, roughly the right magnitude, and it renders convincingly in a console.
What follows is a familiar sequence. The list endpoint returns ids, the client stores them, the detail request uses one, and the API answers 404 for a record that visibly exists in the list. Nobody suspects the id because ids do not change. The rounding happened inside JSON.parse, several layers before anyone looked.
Two things make it worse. It is data-dependent, so a development database with ids in the thousands never reproduces it and production with nineteen-digit snowflakes fails immediately. And it survives round trips convincingly: parse the wrong value, stringify it again, and you get a perfectly valid JSON document containing a number that was never sent.
Twitter hit this in 2010, when the API's id field crossed the threshold and every JavaScript client started requesting neighbouring tweets. The fix was id_str, the same value as a string, delivered alongside. Fifteen years later that is still the standard answer, which says something about how thoroughly the problem is not solvable on the client.
What the JSON spec actually says
The grammar in RFC 8259 puts no limit on a number: optional minus, digits, optional fraction, optional exponent, any length. 1e400 and a hundred-digit integer are both valid JSON.
The spec then adds an interoperability note rather than a rule: it observes that implementations widely use IEEE 754 binary64 and that numbers outside that range risk being handled inconsistently. So the document is valid and the outcome is undefined, which is the least helpful combination available.
RFC 7493, the I-JSON profile from March 2015, makes it a rule: numbers should stay within -(253)+1 to 253-1, and anything outside should be sent as a string. If you are designing an API, that sentence is the whole policy, and it is worth putting in the API's own documentation because the constraint is invisible in a schema that just says integer.
The same payload in five languages
Send {"id": 9007199254740993} and the answers differ:
| Language | Result |
|---|---|
| JavaScript | 9007199254740992, silently rounded |
| Python | 9007199254740993, arbitrary-precision int |
| Java (Jackson) | 9007199254740993 as long or BigInteger |
| Go | exact into an int64 field, rounded into interface{} |
| PHP | exact as int on 64-bit builds |
This is why the bug is so hard to argue about across teams. The backend serialises correctly, its tests pass, a curl against the endpoint shows the right digits, and the value is only damaged inside the browser. Go deserves a note: it is exact when you decode into a typed struct field and lossy when you decode into interface{}, because the default for an unknown number is float64. Generic middleware that unmarshals to a map and re-marshals will therefore corrupt large integers on a server that never had a JavaScript engine in it.
Command-line tools are not automatically safe either. jq historically converted every number to a double; version 1.7 preserves the literal precision of numbers it does not modify, so an id passed straight through survives while the same id incremented does not.
How to send a 64-bit id
Send it as a string. This is the answer. {"id": "1234567890123456789"} costs two bytes and removes the problem for every consumer in every language. Identifiers are not arithmetic operands: you never add two ids together, so the only thing you lose is the ability to sort them numerically, which is a localeCompare with numeric: true away.
Or send both, Twitter's approach, when you cannot break existing clients. The numeric field stays for compatibility, the string field is authoritative, and the documentation says so plainly. It is a migration state rather than a design, so give it an end date.
Or use identifiers that are not numbers at all. A UUID is a string by definition, and if you were reaching for a snowflake because you wanted time-ordered keys, UUIDv7 gives you the ordering with none of the precision exposure, which is the case we make in UUID v4 vs v7. Our UUID generator produces v7 in bulk with the embedded timestamp decoded if you want to see what the ordering looks like.
On the client, when you cannot change the API: use a parser that never creates a Number for large values. json-bigint is the common choice and returns BigInt outside the safe range. The platform is catching up here too: the TC39 proposal for JSON.parse source text access, at stage 3, hands the reviver the original digits so you can build a BigInt from them, with JSON.rawJSON for serialising back without quotes. Until that ships, remember that a reviver alone cannot help, because by the time it runs the value has already been rounded.
Whatever you pick, be consistent across the API. A field that is a string on one endpoint and a number on another produces the same 404 through a different route.
The other half: decimals and money
The same 53 bits break in the other direction with fractions. 0.1 has no exact binary representation, so 0.1 + 0.2 is 0.30000000000000004 in JavaScript, Python, Java and everywhere else that uses binary64. The classic demonstration is 1.005.toFixed(2) returning "1.00", because the stored value is slightly below 1.005.
For money, do not put a decimal float in JSON. Two options, both fine:
- Integer minor units:
{"amount": 1999, "currency": "EUR"}. This is what Stripe's API does, and it sidesteps floating point entirely. Watch for currencies that are not two-decimal: JPY has none, and a few have three. - A decimal string:
{"amount": "19.99"}, parsed into a decimal type on arrival. More readable, and it requires every consumer to remember not to callparseFloat.
The related trap is downstream of the API rather than in it. Export those values to CSV and open the file in Excel and you get a second, unrelated rounding, since Excel keeps fifteen significant digits and treats long numbers as scientific notation. If your JSON survives the trip and the spreadsheet ruins it, the causes are laid out in why Excel ruins your CSV files, and our JSON to CSV converter is built to keep long identifiers as text rather than handing Excel an excuse.
Catching it before production does
Three checks, all cheap.
Put a realistic id in your fixtures. If production ids are nineteen digits, test data with id: 1 proves nothing. One fixture with a real snowflake catches the entire class at the point where it is a one-line fix.
Assert on the boundary. A test that parses a payload containing 9007199254740993 and asserts the value survives is four lines and never needs touching again.
Grep the response, not the object. Read the body as text and compare the digits against what you hold after parsing. This is the only check that distinguishes "the server sent the wrong number" from "we broke it on arrival", and it takes one line in a test helper.
In review, the heuristic is simpler still: any identifier with more than fifteen digits arriving as a JSON number is a defect, whether or not it has caused an incident yet. It is one of the few bugs where the size of the failure is decided by how long it takes someone to notice.
Large numbers in JSON
What is Number.MAX_SAFE_INTEGER and why is it 9007199254740991?
It is 2^53 - 1, the largest integer JavaScript can represent where no other integer shares the same double-precision value. IEEE 754 binary64 gives 53 bits of significand, so every integer up to 2^53 has an exact representation and beyond that only every second, then every fourth, and so on. 9007199254740992 and 9007199254740993 are the same double, which is why the second one comes back as the first.
Why do the last digits of my 64-bit ID change in JavaScript?
Because JSON.parse turned it into a double and rounded it to the nearest representable value. A Twitter or Discord snowflake, a Postgres bigint or any other 64-bit identifier needs up to 19 digits, and JavaScript stops being exact after 16. Nothing throws: 1234567890123456789 silently becomes 1234567890123456800, and the record you then request does not exist. Twitter hit this in 2010 and added an id_str field alongside id, which is still the standard fix.
Does the JSON specification limit how large a number can be?
The grammar in RFC 8259 does not: a JSON number is an arbitrary sequence of digits and any precision is legal on the wire. The spec then notes that interoperability depends on implementations, which mostly use IEEE 754 binary64, and RFC 7493 (I-JSON) turns that into a rule by requiring numbers to stay within plus or minus 2^53 - 1. So a large integer is valid JSON that some parsers will quietly mangle, which is the worst of both worlds.
How do I parse a large integer from JSON in JavaScript without losing precision?
You need a parser that sees the digits before they become a Number, because a JSON.parse reviver runs after the damage. The practical options are a library such as json-bigint, which yields BigInt for values outside the safe range, or reading the raw text yourself. A TC39 proposal for JSON.parse source text access, at stage 3, adds a source string to the reviver arguments so you can construct a BigInt from the original digits, and JSON.rawJSON for the way back.
Why does JSON.stringify throw on a BigInt?
Because there is no agreed JSON representation for one and the committee refused to guess. TypeError: Do not know how to serialize a BigInt is deliberate: emitting 123n would be invalid JSON, and emitting 123 as a number would recreate the precision loss the BigInt was there to prevent. Serialize it yourself with a replacer that converts BigInt to a string, and be consistent, because a field that is sometimes a string and sometimes a number is worse than either.
Should monetary amounts be JSON numbers?
Not as floats. 0.1 + 0.2 is 0.30000000000000004 in every IEEE 754 language, and the errors accumulate across a basket of line items until a total is off by a cent and an accountant asks why. Send integer minor units (1999 for 19.99) with the currency alongside, which is what Stripe does, or send a decimal string and parse it into a decimal type. What matters is that the value never becomes a binary floating point number on the way through.
Does this happen in Python, Java or Go as well?
Not for integers. Python has arbitrary-precision ints, so json.loads handles a 30-digit number exactly; Java’s Jackson maps a large integer to long or BigInteger; Go’s encoding/json will decode into an int64 field without complaint, though it does use float64 when the target is interface{}. That asymmetry is exactly why the bug is so hard to see: the backend team’s tests pass, and the value only breaks on the JavaScript hop.
How do I tell whether a number was already truncated?
Number.isSafeInteger(value) tells you whether the value you hold can be trusted, but not whether it was correct on the wire. The reliable check is textual: compare String(parsed) against the digits in the raw response, which you still have if you read the body as text before parsing. In a review, the faster heuristic is to look for any identifier with more than 15 digits arriving as a JSON number and treat it as a defect regardless of whether it has misbehaved yet.