Two record IDs become one JavaScript key
The decimal identifiers 9007199254740992 and 9007199254740993 are different. Parse them as ordinary JSON numbers in JavaScript and this example gives both the same numeric value. A Map built from those parsed values has one key. Keeping the identifiers as JSON strings gives two keys.
That failure can change which record an SDK selects, even if the server and database store the original integers exactly. Choose an opaque decimal-string contract for identifiers that can exceed JavaScript's safe-integer range. Preserve the string from serialization through lookup and retry construction. Converting a rounded number back to a string does not recover its original digits.
The downloadable lab executes eight boundary values in Node.js 24.20.0. Four change when parsed as JSON numbers and converted back to decimal text. All eight survive the string path. Python's standard json.loads also preserves all eight numeric tokens as integers in the same lab. The discrepancy is a runtime representation boundary, not evidence that JSON text always loses large integers.
Download the Python runner, the JavaScript lab and the saved results. Put both scripts in a writable directory and run python3 experiment.py with a compatible Node runtime on your PATH. The source-aware reviver portion requires JSON.parse support for context.source. The published run records its Node and Python versions.
Read the digits before interpreting the number
Start with the bytes the producer sent. Did the response contain "id":"9007199254740993", or did it contain "id":9007199254740993? Those payloads have different types. A log captured after JSON parsing may already show the rounded value and cannot establish the original token.
The lab uses decimal strings as its source fixture, then constructs the numeric JSON token from those digits. It avoids a JavaScript numeric literal for the original value because that literal can itself be rounded before any serializer runs. Compare source digits with the value after each boundary instead of treating the first in-memory number as ground truth.
RFC 8259 allows JSON implementations to limit numeric range and precision. It identifies the integer range from -(2**53)+1 through (2**53)-1 as exactly interoperable for the discussed binary64 implementations. JSON syntax accepting a longer integer is therefore different from every client preserving that integer.
JavaScript's `Number.MAX_SAFE_INTEGER` is 9007199254740991. Some integers above it remain exactly representable, but neighboring integers are no longer all distinguishable. An exact result for one test value above the boundary is not a safe representation contract for the whole identifier space.
Follow the round trip through the lab
Each row records the original decimal string, JavaScript's numeric round trip and its string round trip. The table separates exact numeric examples from altered ones so a test suite cannot declare success after trying only a conveniently representable large value.
| Original ID digits | JavaScript numeric round trip | String round trip |
|---|---|---|
9007199254740990 | Exact | Exact |
9007199254740991 | Exact | Exact |
9007199254740992 | Exact, outside safe range | Exact |
9007199254740993 | 9007199254740992 | Exact |
9007199254740994 | Exact, outside safe range | Exact |
9007199254740995 | 9007199254740996 | Exact |
9223372036854775807 | 9223372036854776000 | Exact |
18446744073709551615 | 18446744073709552000 | Exact |
The last two displayed values are JavaScript's decimal text representation of the parsed numbers. The lab compares that text with the original digits. It does not assume the displayed decimal text describes every internal binary detail.
Python is a useful negative control here. The runner parses the same eight numeric tokens and asserts that each decoded value has type int and retains the source digits. Python's JSON documentation describes the integer decoding hook and its default integer conversion. Passing the server-side check while failing the JavaScript check is precisely the cross-language discrepancy the lab is designed to expose.
Inspect mapCollision in the result file. Its two source IDs produce one numeric key and two string keys. This is an identity failure even though there is no arithmetic in the application. Deduplication sets, client caches and selected-row state can depend on key equality as much as a database lookup does.
Specify an identifier grammar instead of accepting coercion
A string contract still needs a domain. The fixture chooses canonical unsigned 64-bit decimal text: 0 or a nonzero first digit followed by decimal digits, with a maximum value of 18446744073709551615. It limits length before conversion and uses BigInt only for an exact range check. This is a sample policy. A product that starts IDs at one should reject zero too.
Ten validation cases execute that policy. The values 0, 9007199254740993 and the maximum unsigned value are accepted. An out-of-range value, a leading-zero representation, a plus sign, exponent notation, a negative value, leading whitespace and the numeric value 42 are rejected. Rejecting a small numeric input keeps the published type contract consistent instead of changing it according to the value's current size.
Write the string type into generated-client inputs and outputs. A numeric schema with an int64 annotation is not proof that a generator preserves 64-bit identity in every language. Inspect the generated representation and execute its decoder. A validator run after conversion to a JavaScript number cannot validate digits that have already been discarded.
Avoid silent normalization unless the identifier domain defines it. If "00042" and "42" denote different external records, stripping zeros is corruption. The fixture rejects the former because its invented domain declares one canonical representation. It does not establish a normalization rule for phone numbers, invoice numbers or someone else's integration identifiers.
String ordering also needs a decision. Lexicographic order puts "10" before "2". If an endpoint promises numeric ID ordering, keep that rule on the server or compare exact integers in an explicitly supported client path. Do not change pagination ordering as an accidental consequence of changing the wire type.
BigInt needs an explicit JSON boundary
BigInt("9007199254740993") can represent the source integer exactly. BigInt(Number("9007199254740993")) receives an already-rounded number. The lab executes the latter and gets 9007199254740992. A larger destination type cannot reconstruct discarded information.
Default JSON.stringify also does not serialize a BigInt value. The fixture catches a TypeError for that call. The ECMAScript JSON algorithms define this behavior. When an application uses BigInt internally, serialize an identifier deliberately as decimal text at the wire boundary rather than globally changing serialization as an incidental fix.
An ordinary reviver that calls String(value) sees the numeric value after parsing. Its output in the lab still contains the altered digits. The same fixture tests a supported source-aware reviver: for the id member it reads context.source, constructs a BigInt from the original token and emits decimal text. That path preserves all eight source values in the recorded Node run.
This alternative belongs in a controlled client contract. A receiver needs the relevant runtime support, schema-directed field selection and appropriate limits before parsing large tokens. Do not turn every number into an identifier or use a regular-expression replacement over arbitrary JSON text: numeric-looking content can occur inside strings and unrelated fields. The fixture covers primitive id tokens, not a production lossless parser, duplicate-key policy or an unbounded input.
For a new interoperable API, producer-emitted strings give ordinary JSON clients the intended type directly. A source-aware parser can help when an upstream numeric contract cannot change, but every downstream serialization boundary must preserve its exact result.
Migrate the contract where an ID is used
Trace one identifier from the response bytes to the operation that sends it back. Include generated SDK decoding, UI selection state, URL construction and the serialized request. Add background work and cache keys if the workflow passes through them. The acceptance criterion is equality of the original canonical digits at every identity boundary.
Treat a number-to-string response change as a consumer migration. Existing code can depend on numeric comparison, schema validation or arithmetic. Define whether a versioned endpoint or a temporary distinct string field is appropriate, then measure the supported clients' behavior. The API deprecation planning article supplies a consumer-evidence approach for making that transition reviewable.
Retries need the same record identity as the original attempt. An idempotency key does not repair a request whose resource ID changed before dispatch. The API idempotency cases address uncertain outcomes after a request. The string contract protects the identifier used to build that request.
Carry the lab's collision pair through each supported real client and assert that two distinct records remain separately selectable. Exercise the maximum accepted value and an out-of-range rejection. Preserve the existing server authorization checks when changing the representation: an exact identifier is still an untrusted request parameter. The supplied lab used no SDK, network, queue or database, so those integration checks remain work for the application test suite.
Sources
Documentation checked .

Dreamtsoft Editorial
Editorial follow-up: the source-aware reviver is tested only in the recorded Node runtime. A decimal-string contract avoids requiring that parser feature in every client. Include the generated SDK and queue consumer in the next round-trip check.