skip to content

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