skip to content

Safe Integers and BigInt

Once an ID or counter passes 2^53 a Number silently loses digits, which is exactly why BigInt exists. A common interview scenario is carrying a 64-bit database ID to the browser without corrupting it.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In JavaScript, why does the expression 9007199254740993 === 9007199254740992 evaluate to true, and what does Number.MAX_SAFE_INTEGER have to do with it?

level: middleimportance: must knowfreq 65%

answer

  1. the rounding happens at parse time
  2. fifty-three bits of significand
  3. above the boundary, spacing becomes two
  4. n and n + 1 must stay distinct
  5. 2^53 is representable, 2^53 + 1 is not

basics

~20 s

A JavaScript number carries 53 bits of significand, so integers above 2^53 can no longer all be represented; the literal 9007199254740993 rounds to 9007199254740992 at parse time. Number.MAX_SAFE_INTEGER, 2^53 - 1, marks the last integer whose successor is still distinct.

solid answer

~40 s

Both literals are `number` values, and a number is an IEEE-754 double with a 53-bit significand. Every integer up to 2^53 is exactly representable, but from there upward the representable integers are spaced two apart, then four apart, and so on. `9007199254740993` is 2^53 + 1, which has no representation, so the source text is rounded to the nearest representable value — `9007199254740992` — before the comparison ever runs. `===` then compares two identical numbers and yields true. `Number.MAX_SAFE_INTEGER` is `9007199254740991`, i.e. 2^53 - 1: the largest integer `n` where `n` and `n + 1` are still different values. Past that boundary increments can be lost, which is why `Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2` is also true.

code

javascript · 7 lines
javascript
console.log(9007199254740993);                      // 9007199254740992
console.log(Number.MAX_SAFE_INTEGER);               // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2); // true

const exact = 9007199254740993n;
console.log(exact.toString());                      // "9007199254740993"
console.log(Number(exact));                         // 9007199254740992

go deeper

for a junior

Recall that JavaScript has one numeric type for ordinary arithmetic, that it stops representing every integer past about 9 quadrillion, and that Number.MAX_SAFE_INTEGER names that boundary.

for a middle

Explain the mechanism: 53 bits of significand, the literal rounded at parse time, spacing of 2 above 2^53, and why the constant is 2^53 - 1 rather than 2^53. Reach for MAX_SAFE_INTEGER + 1 === MAX_SAFE_INTEGER + 2 as the demonstration.

for a senior

Show where this bites in running systems — 64-bit identifiers, id-based pagination cursors, nanosecond timestamps — and be clear that detection at the consumer is only a tripwire; the fix belongs at the producer or in the type chosen.

for a principal

Be ready to set the rule for the whole surface: which numeric fields may cross a boundary as JSON numbers, when an identifier becomes an opaque string by policy, and how you migrate an existing contract whose IDs are approaching the limit.

## The value is wrong before any code runs The usual first instinct is that `===` did something clever. It did not. The rounding happens when the *literal* is turned into a value. There is no integer type in ordinary JavaScript arithmetic; every numeric literal without an `n` suffix becomes a `number`, which is a 64-bit IEEE-754 double. If the mathematical value of the literal is not representable as a double, the closest representable double is used. ```js console.log(9007199254740993); // 9007199254740992 console.log(9007199254740993 === 9007199254740992); // true ``` So the engine is comparing one value with itself. ## Where 2^53 comes from A double splits its 64 bits into a sign bit, 11 exponent bits and 52 stored significand bits. For normal values there is an implicit leading 1 bit, giving 53 bits of precision. An integer needs at most 53 significant bits to be written exactly. - Integers with magnitude up to 2^53 need at most 53 bits and are all exact. - 2^53 + 1 needs 54 bits, so it has no representation. - Between 2^53 and 2^54 only even integers are representable — the spacing is 2. - Between 2^54 and 2^55 the spacing is 4, and it keeps doubling. That is why the failure is silent and progressive rather than a clean overflow: nothing throws, nothing becomes `Infinity`, the value simply snaps to a neighbour. ## Why the constant is 2^53 - 1 and not 2^53 `Number.MAX_SAFE_INTEGER` is `9007199254740991`. The value 2^53 itself *is* exactly representable, so the constant is not "the last representable integer". "Safe" is defined as: `n` is safe when `n` and every integer between it and zero has exactly one double representation — practically, when `n` and `n + 1` are distinguishable. At 2^53 that fails, because 2^53 + 1 rounds back down onto 2^53. Hence the largest safe integer is one below. ```js Number.MAX_SAFE_INTEGER; // 9007199254740991 Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2; // true Number.isSafeInteger(2 ** 53); // false ``` That second line is the demonstration to reach for in an interview: two mathematically different expressions producing the same value is exactly what breaks counters, cursors and identifiers. ## What actually breaks in production Three recurring shapes: 1. **Identifiers.** A 64-bit database or platform ID exceeds 2^53 long before it exceeds its own type. Once it becomes a JavaScript number, the low digits are gone: lookups miss, and two genuinely different records can collapse onto one value and be deduplicated together. 2. **Monotonic counters and cursors.** An `id + 1` pagination cursor stops advancing once increments are being rounded away, producing an infinite loop that fetches the same page forever. 3. **Nanosecond or microsecond timestamps.** Millisecond epoch values are safe for centuries, but nanosecond timestamps pass 2^53 immediately and lose their low-order digits. Note that the loss is unrecoverable at the point of detection. `Number.isSafeInteger(v)` tells you the value cannot be trusted; it cannot tell you what the digits were. ## What to do instead The options are all about keeping such values out of the `number` type in the first place: - Carry the value as a **string** when you only need to store, compare for equality and echo it back. This is the common answer for opaque identifiers, because it needs no arithmetic. - Use **`BigInt`** (`9007199254740993n`) when you genuinely need exact arithmetic beyond the safe range. BigInt is arbitrary precision, so no boundary applies — at the cost of not mixing with `number` in arithmetic. - Keep the value **below the boundary by design**, for example by using milliseconds rather than nanoseconds, or by splitting a composite key into fields. ```js const exact = 9007199254740993n; console.log(exact === 9007199254740992n); // false — BigInt keeps the digits console.log(Number(exact)); // 9007199254740992 — converting back loses them again ``` ## The sentence that answers the question "Numbers are doubles with 53 bits of precision, so 2^53 + 1 has no representation and the literal is rounded at parse time; `MAX_SAFE_INTEGER` is 2^53 - 1 because that is the last integer whose successor is still a different value."

  • If the value is already wrong, why does Number.isSafeInteger still earn its place in the code?
    It converts a silent corruption into a loud failure at the boundary where the data enters. You cannot recover the lost digits from the rounded value, but you can refuse to act on it — reject the payload, log the field, and fix the producer to send a string or use BigInt. Without the check the bug surfaces much later as a missing record or a stuck cursor.
  • Above 2^53, what is the spacing between representable integers, and does it stay constant?
    It doubles with each binade. Between 2^53 and 2^54 only even integers are representable, so the spacing is 2; between 2^54 and 2^55 it is 4, then 8, and so on. That is why large values degrade gradually rather than failing at one cliff — a value near 2^60 has already lost its bottom several digits.
  • Does the number overflow to Infinity once it passes the safe integer boundary?
    No. `Infinity` only appears near `Number.MAX_VALUE`, around 1.8e308. Between 2^53 and that limit, values are perfectly finite and usable — arithmetic works, comparisons work, formatting works. The only thing you lose is the guarantee that distinct integers stay distinct, which is precisely what makes the failure so easy to miss.

saying these in an interview costs you the question

  • Says === is doing a loose or fuzzy comparison here
  • Claims values above the boundary become Infinity
  • Thinks the limit is 2^31 or 2^32
  • Believes a reviver or rounding fix can restore the digits
  • Assumes toFixed or toString recovers the original number

context

open as a page

In JavaScript, what does Number.isSafeInteger(value) check that Number.isInteger(value) does not, and what do both do with a non-number argument?

level: juniorimportance: should knowfreq 40%

basics

~20 s

Number.isInteger asks only whether the value is a whole number; Number.isSafeInteger additionally requires it to lie within plus or minus 2^53 - 1, the range where every integer is uniquely representable. Both return false for any non-number, with no coercion.

open as a page

In JavaScript, why does 1n + 1 throw a TypeError while 1n == 1 and 1n < 2 are both true?

level: middleimportance: should knowfreq 50%

basics

~20 s

Arithmetic operators refuse mixed BigInt and number operands, because implicitly converting either way could silently lose precision — the exact problem BigInt exists to prevent. Comparison operators are exempt: they compare mathematical values, so mixed comparisons are safe and allowed.

open as a page

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?

level: seniorimportance: should knowfreq 42%

basics

~20 s

JSON.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.

open as a page

For a JavaScript codebase handling values that exceed the safe integer range, how do you decide between representing them as BigInt, as opaque strings, or keeping them as numbers, and what does each choice cost across the system?

level: principalimportance: nice to knowfreq 25%

basics

~20 s

The deciding question is whether you do arithmetic on the value. Opaque strings suit identifiers you only compare and echo; BigInt suits genuine exact arithmetic and costs you JSON serialization, Math support and mixing with numbers; plain numbers stay only where values provably stay under 2^53.

open as a page