Large JSON IDs in JavaScript: How to Keep Every Digit
An API can return the right ID while JavaScript reads a different one. Reproduce the precision loss, keep IDs as strings, and handle numeric APIs safely.
An API response contains 9007199254740993, but your JavaScript object contains 9007199254740992. The request succeeded. The JSON is valid. The last digit still changed.
This can happen when an integer becomes a JavaScript Number. For identifiers that do not need arithmetic, the practical fix is to send and retain them as strings, starting before any conversion can discard precision. Calling String() on the rounded result preserves the wrong digits. MDN's JSON parsing documentation explains why that conversion can happen during parsing.
This guide covers decimal IDs in JavaScript and TypeScript applications: how to reproduce the problem, locate the first lossy conversion, fix the API contract, and handle an upstream API that still sends numeric IDs.
Reproduce the problem with two different IDs
Run this example in a JavaScript console or Node.js:
const responseText =
'[{"id":9007199254740992},{"id":9007199254740993}]';
const records = JSON.parse(responseText);
console.log(records[0].id); // 9007199254740992
console.log(records[1].id); // 9007199254740992
console.log(records[0].id === records[1].id); // true
console.log(new Set(records.map(record => record.id)).size); // 1 The response text contains two distinct integer tokens. After ordinary parsing, both IDs have the same value. The Set example makes the consequence visible: a collection that should contain two identifiers now contains one.
If your application uses those values as cache keys or sends them back in a request, the problem can spread beyond how the ID looks on screen.
The JavaScript examples in this article were executed in Node.js 24.22.1 on 29 October 2026. The advanced parser example later in the guide needs support for the reviver's context.source; check that capability in your target runtime.
Where JavaScript's safe integer range ends
JavaScript's Number.MAX_SAFE_INTEGER is 9007199254740991, or 2 to the power of 53, minus 1. The corresponding lower limit is -9007199254740991. Inside that interval, integer values can be represented exactly and distinguished from other integer inputs. Beyond it, that guarantee ends. The ECMAScript specification defines the boundary and the rounding collision.
That does not mean every larger integer changes. Compare these actual parse results:
| Integer written in the JSON | Result after ordinary JavaScript parsing |
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Some values remain exact, while an adjacent value rounds to the same number. Around 2 to the power of 53, the gaps become visible:
Offsets from 2⁵³ (9,007,199,254,740,992). Filled points are exactly representable. The inputs at +1 and +3 round to 0 and +4 respectively. The safe range ends at −1.
JSON itself does not impose JavaScript's safe integer interval on its number syntax. RFC 8259, section 6, permits implementations to limit numeric precision and identifies this interval as one where commonly used implementations agree exactly. A server can therefore produce valid JSON that its JavaScript client cannot represent faithfully using ordinary numbers.
For a record ID, “close enough” is usually the wrong requirement. You need to preserve which record it identifies.
Fix the contract: keep IDs as strings before they can be rounded
If you control the API, represent a potentially large identifier as a JSON string:
{
"id": "9007199254740993",
"quantity": 2
} The identifier remains text; a bounded quantity can remain numeric. Choose the representation for what the field means, rather than converting every field to the same type.
const order = JSON.parse(
'{"id":"9007199254740993","quantity":2}'
);
console.log(typeof order.id); // string
console.log(order.id); // 9007199254740993
console.log(JSON.stringify({ id: order.id }));
// {"id":"9007199254740993"} An ID can stay a string while you display it, check whether it equals another ID, use it as a map key, or include it in a URL. Avoid an unnecessary Number(id), unary +id, or parseInt(id, 10) on the way.
This is an established API design choice. Discord documents its Snowflake IDs as strings in its HTTP API, explaining that the identifiers can occupy up to 64 bits. The OpenAPI int64 format registry also recommends JSON strings for values outside the safe 53-bit interval.
Make the schema and the actual response agree
Start by making the string representation explicit in the property definition:
id:
type: string
minLength: 1
example: '9007199254740993' This example requires a non-empty string. Add the character, length, range and formatting rules your identifier system actually requires. A digit-only ID, a UUID and an identifier with meaningful leading zeroes need different validation rules.
A schema label such as int64 cannot change what an ordinary JavaScript JSON parser does. Check the generated client, runtime validator and response body together. If an existing API currently sends numbers, changing them to strings is a contract change: coordinate it with consumers or version the affected response.
Check the database driver before the serializer
The fix must start while the original value is still exact. A Node.js server that receives an already rounded number from a database adapter cannot repair it with String(row.id) before sending the response.
Inspect the adapter's documented handling of large integer columns. Keep an exact representation through that boundary, such as a decimal string or a BigInt, then deliberately serialise the ID as a string. If the value is already wrong at this stage, changing browser code comes too late.
Why common conversions do not recover the missing digit
Once the numeric value has been rounded, neither its string form nor its BigInt form contains the original integer:
const parsed = JSON.parse('{"id":9007199254740993}');
console.log(String(parsed.id));
// 9007199254740992
console.log(BigInt(parsed.id).toString());
// 9007199254740992
console.log(BigInt("9007199254740993").toString());
// 9007199254740993 The last line works because it starts with the exact decimal text. The other two start with the same rounded number.
A conventional reviver that returns String(value) for an ID field has the same problem: its numeric value has already been parsed. A reviver with access to the source text can take a different route, covered below.
TypeScript also cannot repair this through a type annotation or an as { id: string } assertion. The TypeScript handbook states that assertions have no runtime effect. If you assert that a numeric response contains strings, the compiler may accept your assertion while the running program still holds numbers. Validate the incoming type; do not use a cast to stand in for conversion or validation.
Use BigInt for exact integer arithmetic
BigInt is useful when a large value needs exact integer operations. Construct it from an exact decimal string or a bigint literal with an n suffix. Converting it back to Number can reintroduce precision loss; MDN's BigInt reference explains the distinction and the restrictions on mixing numeric types.
For an ID that only needs storage and equality checks, keeping the original string avoids extra conversions. It also preserves leading zeroes where those are meaningful: BigInt("00123").toString() produces "123".
If your application already holds an exact BigInt, convert the relevant field explicitly when creating JSON:
const exactId = BigInt("9007199254740993");
const responseBody = JSON.stringify({ id: exactId.toString() });
console.log(responseBody);
// {"id":"9007199254740993"} Passing a BigInt directly to JSON.stringify() throws a TypeError by default. An explicit conversion or a carefully defined replacer supplies the serialisation policy; the JSON.stringify documentation describes that behaviour. For a small number of known ID fields, explicit conversion makes the intended wire format easy to review.
If you cannot change the API, preserve the original token
Sometimes an upstream service sends numeric IDs and you cannot change its contract. You need access to the original JSON text and a parsing path that preserves the relevant token.
In runtimes that support it, a JSON.parse reviver receives context.source, which exposes the source text for the primitive value being processed. That lets you use the original digits even though the ordinary numeric value has already been converted. MDN documents source access and its compatibility caveat.
This narrowly scoped example expects decimal IDs in fields named id. It accepts existing digit strings and preserves unsigned integer tokens as strings:
function parseWithStringIds(text) {
return JSON.parse(text, (key, value, context) => {
if (key !== "id") return value;
const digits = typeof value === "string"
? value
: context?.source;
if (typeof digits !== "string" ||
digits.length === 0 || /[^0-9]/.test(digits)) {
throw new TypeError(
"id needs a digit string or integer token with source access"
);
}
return digits;
});
}
const recovered = parseWithStringIds('{"id":9007199254740993}');
console.log(recovered.id); // 9007199254740993
console.log(typeof recovered.id); // string The regular expression validates one already isolated field value. It deliberately rejects negative, fractional and exponent forms. The key check also affects nested fields named id; adapt it to your real schema and validate required fields and allowed ranges separately. Other numeric fields are still parsed normally.
On a runtime without source access, the example rejects a numeric ID instead of quietly accepting the rounded value. For such a target, use a parser that preserves numeric tokens or change the upstream representation. Test the runtime you ship, including any older browsers you support.
With Fetch, use response.text() to obtain the text before custom parsing, rather than consuming that same response with response.json() first. An HTTP client that has already converted the response into an object needs to be configured to expose the original text, or to perform the appropriate parsing at that boundary.
Avoid a global regular-expression replacement that puts quotation marks around long runs of digits in an entire JSON document. A parser needs to distinguish number tokens from digits inside strings and handle escapes and numeric notation correctly.
Find the first place the ID changes
Trace one known identifier through the application. Use the exact expected digits from the source system, stored as a string in your investigation.
| Observation | Where to look next |
|---|---|
| The database value is exact, but the server-side value is already different | Database driver, ORM configuration or an earlier numeric conversion |
| The unparsed response text contains the right digits, but the parsed object does not | JSON parsing or the HTTP client's response transformation |
| The response sends an exact string and parsing preserves it, but a later value changes | Calls such as |
| The application keeps the exact string, but a request uses a different ID | Request construction, state updates, lookup logic or another conversion before sending |
Inspect the original response text, not only a tool's parsed object preview. Compare both the value and typeof at the relevant boundary. If the text itself is wrong, no client-side parser can infer which digits the server originally intended.
For a numeric field that is explicitly required to stay within the safe integer interval, Number.isSafeInteger() is a useful guard. Number.isInteger() is weaker: an already rounded result can still be an integer. Neither check reconstructs an original value, and checking a parsed number does not validate the exact spelling of its source token. See the safe integer definition and examples.
Verify the fix through the real data path
A small identifier such as 42 will not expose this failure. Include fixtures around the boundary and at the size your API actually uses:
const expectedIds = [
"9007199254740991",
"9007199254740992",
"9007199254740993",
"9223372036854775807"
];
for (const expectedId of expectedIds) {
const body = JSON.stringify({ id: expectedId });
const received = JSON.parse(body);
if (typeof received.id !== "string" || received.id !== expectedId) {
throw new Error(`ID changed: ${expectedId}`);
}
}
console.log("All ID strings survived the JSON round trip."); This is a local check of the string contract. In your application test, send the fixtures through the actual database adapter, server serializer and client parser. Assert that two adjacent IDs remain distinct, then confirm the client sends the exact original ID back when selecting a record. Add a leading-zero fixture only if your identifier format permits one.
Keep expected values as strings or bigint literals in tests. Writing the supposedly exact expectation as a large JavaScript number literal can round the test fixture before the assertion even runs.
The fix is complete when every boundary preserves the identifier's exact value and agreed type. For an opaque ID, keeping it as a string makes that requirement straightforward to check.