Skip to content
JSON2026-10-11Begin5 min read

JSON Integer Precision Trap: When Snowflake IDs Are Silently Rewritten

Symptom: the ID is “almost” right

A class of bugs that wastes half a day:

  • Backend logs show order/user id 2099774875233849345
  • After a formatter, DevTools, or JSON.parse in the app, it becomes 2099774875233849400
  • Signatures, reconciliation, and id lookups all fail — yet JSON.parse does not throw

This is not truncation on the wire and not “formatting changed whitespace”. It is IEEE 754 double losing low bits while parsing a numeric literal.

Why valid JSON still loses precision

The JSON specs define a number as a decimal literal; they do not require arbitrary-precision integers. Browser and Node JSON.parse map numbers onto JS number (IEEE 754 binary64). Only integers within Number.MAX_SAFE_INTEGER (2^53−1 = 9007199254740991) round-trip bit-exact.

Approx digitsExampleRisk
≤15–16Ordinary autoincrement idsUsually fine
18–19Snowflake / some distributed idsSilent rounding
Fractions / scientific1e20, 0.1Separate float semantics

Console check:

JSON.parse('{"id":2099774875233849345}').id
// → 2099774875233849400
String(JSON.parse('{"id":2099774875233849345}').id) === '2099774875233849345'
// → false
Number.isSafeInteger(2099774875233849345) // → false

Snowflake-style ids pack timestamp, worker, and sequence into ~64 bits. That design lands squarely in the 18–19 digit band where binary64 mantissa (~53 bits) cannot represent every integer. Autoincrement ids that stay under ~16 digits usually survive; the moment a distributed id generator crosses the safe frontier, every JS consumer becomes a silent rewriter unless the wire format uses strings.

Language matrix: who rounds, who keeps digits

Runtime / libraryDefault JSON numberLarge integer fate
Browser / Node JSON.parseIEEE 754 doubleSilent rounding past 2^53−1
Python jsonArbitrary-precision intDigits preserved
Go encoding/json into float64DoubleSame trap as JS
Go into json.Number / int64Text or 64-bit intPreserved if typed correctly
Jackson (Java) into long64-bitOK within signed long; still fails if forced through JS

The bug often looks “frontend-only” because the backend already held a correct 64-bit value. A proxy that re-encodes with a double-backed parser can corrupt the payload before it reaches the browser. Treat every hop that calls a stock JSON parser as a suspect.

Three exits (by reliability)

  1. Protocol: IDs as strings
    {"id":"2099774875233849345"} — the most portable long-term convention across languages.

  2. Debug path: lossless parse
    Do not format or inspect with bare JSON.parse. This site’s JSON Formatter uses parseLossless for format/minify so numeric tokens keep their raw text. Useful when you need to see the payload before changing code.

  3. App layer: BigInt / decimal types
    Only when you control both runtimes and the contract; the on-the-wire JSON often still carries strings so intermediate proxies do not re-parse into doubles.

SceneRiskSee
Snowflake ids in API JSONFrontend JSON.parseThis article + formatting guide
Large integers in JWT claimsDecode then parseJWT decode guide
Nested JSON after Base64Encoding lossless, parse still lossyBase64 guide
Diff says numbers differParser corruption vs business changeTroubleshooting handbook

Concrete chain: Base64 → JSON → JWT claim

A common pipeline: service A Base64-encodes a JSON blob, service B decodes bytes then JSON.parses, and a claim later lands in a JWT payload. Base64 never changes digit characters — the damage happens at parse. If you only inspect the Base64 tool output as text, the id still looks correct; the moment any hop materializes a JS number, the low bits are gone. Debug order: verify the raw JSON token (lossless formatter) → check whether JWT decode displays the claim as a string or a number → only then blame transport.

Implementer's note

The lossless path uses a hand-written recursive-descent parser, wraps number tokens in LosslessNumber{ raw }, and prints raw on stringify instead of Number(raw). A validate-only probe may still call stock JSON.parse — that answers “can it parse?”, not “were values preserved?”. Signing, hashing, and reconciliation must use strings or the lossless tree.

Quick checklist

  1. Paste the suspicious id into the JSON Formatter and compare digit strings to the original token.
  2. If the formatter preserves it but your app does not — search for bare JSON.parse / response.json().
  3. Push the backend to emit string ids; short-term, keep strings or BigInt on the client.
  4. Do not “fix” by formatting again in a lossy tool — that freezes the wrong digits into the clipboard.

Triage: precision vs something else

What you seeLikely causeNext step
Last digits become 0 / round hundredsIEEE 754 roundingLossless path here / string ids
Whole field null / missingField name or serializer configSchema / Diff
Chinese becomes \uXXXXASCII-escape policyUnicode escape guide
Unexpected tokenSyntaxTroubleshooting handbook
Diff shows number mismatch, no business releaseOne side used a lossy parseRe-compare after lossless/string on both sides

Money, FX, and inventory fractions are a different class (decimal vs binary float). Do not conflate them with “integer ids rewritten”. String ids for identifiers; integer minor units or decimal strings for money — both disciplines can hold at once.

FAQ

Does the lossless path keep float tokens verbatim too?

Yes for the literal text (0.50, 1e5). That is not decimal arithmetic precision. Money still belongs in integer cents or decimal strings.

Is this a JSON spec bug?

No. ECMA-404 / RFC 8259 leave number representation to implementations. The engineering mistake is carrying business ids in doubles, not indentation style.

Doesn’t “validation passed” contradict this?

No. Validation asks syntax; precision asks semantics. Green syntax plus a wrong id is the textbook picture of this trap.


This article is provided by ToolVault. Visit the homepage for more developer tools.