A JavaScript client reads records from a JSON API whose primary keys are 64-bit integers, and lookups by those keys intermittently miss while two distinct records sometimes collapse into one. How do you confirm the cause and fix it?
answer
- large keys fail, small keys work
- the parser produces doubles
- two neighbours land on one value
- the reviver runs after the damage
- fix the producer, guard the consumer
basics
~20 sJSON.parse maps every JSON number to a double, so an identifier above 2^53 arrives already rounded and a reviver cannot recover it. Confirm by comparing the raw response text with the parsed value, then have the producer emit such identifiers as JSON strings.
solid answer
~40 sThe symptom is the signature of safe-integer loss. JSON's grammar puts no bound on a numeric literal, but `JSON.parse` produces `number` values, so an identifier beyond `Number.MAX_SAFE_INTEGER` is rounded to a nearby representable double — which is why neighbouring keys can land on the same value and a lookup by the rounded key misses. Confirm it in two moves: log the raw response body as text and compare the digits with the parsed property, and assert `Number.isSafeInteger(id)` on the parsed value. A reviver does not help, because by the time it runs the argument is already a rounded number; the source digits are gone. The fix belongs at the producer: serialize such identifiers as JSON strings, and treat them as opaque strings — or `BigInt(str)` if you need arithmetic — on the client.
code
javascript · 11 linesconst body = '{"a": 9007199254740993, "b": 9007199254740992}';
const parsed = JSON.parse(body);
console.log(parsed.a === parsed.b); // true — two keys collapsed into one
console.log(Number.isSafeInteger(parsed.a)); // false
// The reviver runs too late to help
const revived = JSON.parse(body, (k, v) => (k === 'a' ? BigInt(v) : v));
console.log(revived.a); // 9007199254740992n — wrong digits preserved
// The representation that survives
console.log(JSON.parse('{"a": "9007199254740993"}').a); // "9007199254740993"go deeper
Recall that JSON.parse turns every JSON number into an ordinary JavaScript number, and that very large integer identifiers therefore cannot be trusted after parsing.
Explain the threshold and the collapse: above 2^53 the representable values are spaced apart, so neighbouring keys can round onto the same value, and a reviver receives the already-rounded number.
Walk the diagnosis end to end — raw body versus parsed value, a Number.isSafeInteger assertion at the boundary, the even-digit tell — and land on fixing the producer rather than patching the consumer.
Be ready to argue the contract rule: identifiers travel as opaque strings, the guard lives in one shared validation layer, and any migration of an existing numeric-ID API is versioned and covered by a test that feeds an oversized value.
## Reading the symptom Two details in the report identify the failure before you look at any code. First, lookups *intermittently* miss: small identifiers work, large ones do not, which points at a magnitude threshold rather than a logic bug. Second, distinct records *collapse* into one: two different keys became the same value, which only happens when values are being snapped onto a coarser grid. Both are what happens when a 64-bit integer becomes a JavaScript `number`. ## The mechanism JSON's grammar allows a number literal of any length. `JSON.parse` does not preserve that: it produces values of type `number`, i.e. IEEE-754 doubles with 53 bits of significand. Any integer above `Number.MAX_SAFE_INTEGER` (`9007199254740991`) may have no exact representation and is rounded to the nearest one. Above 2^53 the representable integers are two apart, so two consecutive database keys can round to the same double — the collapse. And the rounded key sent back to the server matches no row — the miss. ```js const body = '{"id": 9007199254740993}'; const parsed = JSON.parse(body); console.log(parsed.id); // 9007199254740992 console.log(Number.isSafeInteger(parsed.id)); // false ``` Nothing throws, nothing warns. That is why the bug survives to production. ## Confirming it, not guessing Three checks, cheapest first: 1. **Compare raw text with parsed value.** Capture the response body as a string before parsing and read the identifier's digits out of it, then compare with `String(parsed.id)`. A mismatch in the low-order digits is proof. 2. **Assert safety on the parsed value.** `Number.isSafeInteger(id)` returning false means the value cannot be trusted. This is the check to install permanently at the parse boundary so the next occurrence fails loudly. 3. **Look at the shape of the bad values.** Once rounding starts, identifiers trend toward even numbers, then multiples of four. A column of IDs that all end in an even digit is a strong tell. ## Why a reviver cannot save you The natural first idea is to pass a reviver to `JSON.parse` and convert to `BigInt` there: ```js JSON.parse(body, (key, value) => key === 'id' ? BigInt(value) : value // too late: value is already 9007199254740992 ); ``` The reviver is invoked with a value the parser has already produced, so the rounding happened before your function ran. `BigInt(9007199254740992)` faithfully preserves the wrong number. This is the single most important thing to say in the interview: **once the parse has happened, the information is gone.** (A proposal to expose the original source text to the reviver has been working through the standards process; it is not something to assume is available, and it does not change the analysis for an engine that lacks it.) ## Fixing it In order of preference: - **Serialize the identifier as a JSON string at the producer.** `{"id": "9007199254740993"}` round-trips exactly, needs no client heroics, and matches how identifiers are actually used — compared for equality, stored, echoed back. Treat them as opaque on the client: never `Number(id)` "just to sort". - **Use `BigInt` on the client when arithmetic is genuinely required**, constructing it from the string form: `BigInt(record.id)`. Remember that `JSON.stringify` throws on a BigInt, so the outbound path needs a replacer or a `toJSON` that emits a string. - **Preprocess the raw text** only as a stopgap you cannot avoid — quoting the numeric field in the body with a targeted transformation before parsing. It is brittle against nested data and string contents that look like numbers, and it should come with a ticket to fix the producer. ```js // Permanent tripwire at the boundary function assertSafeIds(records) { for (const r of records) { if (typeof r.id === 'number' && !Number.isSafeInteger(r.id)) { throw new RangeError(`id ${r.id} exceeded the safe integer range`); } } return records; } ``` ## The wider lesson Any value whose *digits* matter rather than its magnitude — identifiers, account numbers, sequence positions, high-resolution timestamps — should not cross a boundary as a JSON number in a system with JavaScript consumers. The client-side guard is a tripwire, not a repair: the only durable fix is choosing a representation on the wire that JavaScript can hold exactly.
- The producer insists on sending JSON numbers. What can you still do on the client?Only preprocess the raw body text before it reaches `JSON.parse` — quote the offending numeric fields, then build `BigInt` or keep strings. It is a workaround, not a fix: it must know exactly which paths are affected, it can misfire on numbers inside string values, and it has to be maintained as the payload evolves. Ship it with the safe-integer assertion so a missed path fails loudly.
- If the client sends the identifier back, what breaks on the outbound path?If you kept it as a string, nothing — `JSON.stringify` emits it verbatim. If you converted to `BigInt`, `JSON.stringify` throws `TypeError: Do not know how to serialize a BigInt`, so you need a replacer or a `toJSON` that returns the string form. And if you ever rounded it into a number, the outbound request carries the corrupted value with no indication anything went wrong.
- How would you keep this class of bug from recurring across the whole client?Make the guard structural rather than per call site: run parsed payloads through one validation layer that asserts `Number.isSafeInteger` on every numeric field and rejects the response otherwise, and keep identifiers typed as opaque strings in the client model so no code path can casually do arithmetic on them. Add a contract test that feeds a payload with an oversized ID and expects a loud failure.
saying these in an interview costs you the question
- Proposes a JSON.parse reviver that converts to BigInt
- Claims JSON itself limits number size
- Suggests rounding or toFixed to repair the id
- Blames the server database rather than the parse
- Thinks parseInt on the parsed number restores digits