Skip to content
matthewcm.dev

    ↑ ↓ move · ↵ open · tab scopeopen graph →

    Large Integer IDs in JavaScript

    JavaScript numbers are doubles: integers above 2^53 − 1 (16+ digits) are silently rounded by JSON.parse, so a 64-bit ID can change before your next request even though the network response looks right. Send long IDs as strings.

    Seedtended planted 3 min read

    🌱 A rough idea, planted recently. Expect rough edges — it will grow.

    On this page

    [!abstract] The lesson An ID is a label, not a number. If it can be longer than 15 digits, send it as a string. JavaScript can’t hold integers above 9,007,199,254,740,991 exactly, and JSON.parse rounds them without any error.

    What I saw

    Long IDs (64-bit, from a Java back end) came back wrong when a page sent them in a request payload, even though the raw payload in the Network tab looked fine. The ID had been rounded inside the browser, before the request was built.

    Why it happens

    • Every JavaScript number is a 64-bit float (IEEE 754 double). It has 53 bits for the digits, so integers are only exact up to Number.MAX_SAFE_INTEGER = 2^53 − 1 = 9,007,199,254,740,991 (16 digits).
    • Above that, not every integer can be stored, so the value snaps to the nearest one that can. A Java long goes up to about 9.2 × 10^18 (19 digits), far past the safe range.
    • JSON.parse turns every JSON number into a number. There is no error and no warning:
    JSON.parse('{"id": 9007199254740993}').id      // 9007199254740992  (off by one)
    JSON.parse('{"id": 1234567890123456789}').id   // 1234567890123456800
    JSON.stringify({ id: 1234567890123456800 })    // '{"id":1234567890123456800}', so the wrong ID goes out

    Why the raw payload looked fine

    The trap is that the text and the value are different things:

    1. The server sends {"id": 1234567890123456789}. The Network tab’s Response view shows that raw text, which is correct.
    2. fetch(...).then(r => r.json()) parses it. The value in memory is now 1234567890123456800. (DevTools’ Preview view and console.log also show the parsed, rounded value.)
    3. The app puts that ID in the next request. JSON.stringify writes the rounded number, and the server gets an ID that doesn’t exist, or worse, one that does.

    So looking at the wire tells you what the server sent, not what your code holds. The bug lives in the gap between them.

    The fix: IDs are strings

    1. Contract: the API sends 64-bit IDs as JSON strings, {"id": "1234567890123456789"}. Write it into the API contract, so every client gets it right, not only the browser.
    2. Back end (Java/Jackson): serialise Long IDs as strings, per field with @JsonSerialize(using = ToStringSerializer.class), or globally by registering ToStringSerializer for Long.class and long.class on the ObjectMapper. Accept both a string and a number on the way in during the migration.
    3. Front end (TypeScript): type them as id: string. Never Number(id) or parseInt(id) them; compare and use them as strings.
    4. Test it (Contract-Driven Development): a contract test with an ID above 2^53 (for example 9007199254740993) that round-trips unchanged.

    If you can’t change the server

    • Parse with the source text. The JSON.parse reviver now gets a third context argument whose source is the original text (Chrome/Edge 114+, Firefox 135+; check Safari before relying on it):
      JSON.parse(text, (key, value, ctx) =>
        key === "id" && !Number.isSafeInteger(value) ? ctx.source : value)
    • Or a lossless parser library (lossless-json, json-bigint) for the affected responses.
    • BigInt is only for doing arithmetic on huge integers. IDs don’t need maths, and JSON.stringify throws on a BigInt, so strings are simpler.

    Rules of thumb

    Sources

    • #javascript
    • #json
    • #precision
    • #api-design
    • #bigint
    • #jackson
    • #web-development

    Nothing links here yet — it’s a fresh shoot.

    ↪ Links to (4)