JSON Numbers Will Silently Corrupt Your IDs: A Practical Guide to Safe Integer Interop
JavaScript can't represent integers above 2^53 exactly, so 64-bit IDs in JSON payloads get silently rounded. Here's how string encoding and schema validation prevent the corruption.
27 Mar 2026, 21:25 UTC

Your Java backend happily stores a 64-bit order ID like 9007199254740993. You serialize it to JSON, send it to a JavaScript frontend, and somewhere along the way it becomes 9007199254740992 — no error, no warning, just a different number. The user now sees someone else's order. This isn't a hypothetical edge case; it's one of the most common interoperability bugs in systems that mix JavaScript with statically typed languages.
The takeaway: JSON numbers are not a safe transport for large integers. If a value can exceed 253 − 1, encode it as a string, and validate ranges at your schema boundary instead of trusting parsers.
Why JSON numbers are a trap
The JSON specification (RFC 8259, and RFC 7159 before it) defines the syntax of numbers but deliberately leaves their precision implementation-defined. It notes that interoperable integers should stay within the range that an IEEE 754 double can represent exactly: −(253 − 1) to 253 − 1, i.e. ±9007199254740991. Beyond that, a double can't distinguish adjacent integers, so 9007199254740993 and 9007199254740992 collapse to the same value.
JavaScript is the strictest victim here because its only native Number type is a 64-bit float. JSON.parse('9007199254740993') in any browser or Node.js runtime quietly rounds the value. Meanwhile, Java's Jackson maps the same text to a long with full precision, and Python's json module parses it as an arbitrary-precision int. Same payload, three different results — and only one of them is wrong, which makes the bug maddening to trace.
Where this bites in practice
- Database IDs: Snowflake-style IDs, Twitter/X-style IDs, and many sharded key schemes are 64-bit by design and exceed 253 almost immediately.
- Timestamps in nanoseconds: epoch nanos crossed 253 long ago; epoch micros will too.
- Money in minor units: large balances in cents or satoshis can outgrow the safe range in high-volume systems.
- Bitmasks and hashes: 64-bit flags or truncated hashes transmitted as numbers lose bits silently.
The failure mode is always the same: data round-trips fine in your Java or Python tests, then drifts by one or two in the browser, and nobody notices until a customer does.
The fix: strings for big integers, validation everywhere
The widely adopted convention — used by gRPC's JSON mapping for int64/uint64, among others — is to serialize 64-bit integers as JSON strings:
{
"orderId": "9007199254740993",
"totalCents": "184467440737095516",
"quantity": 3
}Small, genuinely numeric values stay as numbers; anything that can exceed the safe range becomes a string. On the JavaScript side, parse with BigInt when you need arithmetic:
const orderId = BigInt(payload.orderId); // 9007199254740993n, exact
// Never do: Number(payload.orderId) if the value can exceed 2**53 - 1Note that JSON.stringify cannot serialize a BigInt directly — it throws a TypeError. Convert to string explicitly, or supply a replacer. On the JVM side, configure your mapper to write longs as strings (for Jackson, WRITE_NUMBERS_AS_STRINGS or a targeted serializer on ID fields) so the contract is symmetric.
Second line of defense: schema validation. If you use JSON Schema, declare the field as "type": "string" with a pattern like ^[0-9]{1,19}$, or if you must accept numbers, set maximum: 9007199254740991. Rejecting out-of-range values at the boundary turns silent corruption into a loud, debuggable 400 response.
A quick round-trip check you can run today
Verify your own stack's behavior before trusting it. In a browser console or Node REPL (no special permissions needed):
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992 ← the off-by-one is silentThen run the equivalent in your backend language — json.loads in Python, Jackson or Gson in Java — and compare. If any hop in your pipeline (including proxies, logging pipelines, or analytics SDKs that re-serialize payloads) parses numbers into doubles, your data is at risk there too. Don't forget intermediaries: a Node-based API gateway or a log shipper that parses and re-emits JSON can corrupt values even when both your services are well-behaved.
The trade-off
String-encoded numbers are ugly, and they cost you something: clients must convert before doing math, sorting in some tools becomes lexicographic ("10" < "9"), and you lose native numeric comparison in query languages and dashboards that consume the raw JSON. For values guaranteed to stay small — counts, quantities, port numbers — plain numbers remain the right call. Reserve the string treatment for identifiers and any field whose range you don't fully control.
Also be aware that BigInt and arbitrary-precision parsing are not free in hot paths, and some older parsers reject or mangle very long digit sequences regardless of quoting. Test the actual libraries and versions you ship, not the spec.
Closing
The rule of thumb is simple: if a number identifies something rather than measures something, and it can exceed 253 − 1, put it in quotes. Audit your API payloads for 64-bit IDs this week, add the round-trip check above to your integration tests, and enforce range limits in your schemas. Silent off-by-one corruption is the worst kind of bug — make it loud before production does.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.