A number in JavaScript gets a fixed amount of space, like a ruler with a fixed number of marks on it. Up to about nine quadrillion, there is a mark for every whole number. After that the marks spread out, and a number that falls between two marks jumps to the nearest one.
The curse
console.log(9999999999999999); // prints: 10000000000000000
Sixteen nines become a one with sixteen zeros. There is no error and no warning. JavaScript quietly rounded your number.
Why it happens
Every normal JavaScript number is a 64-bit floating point value. 53 of those bits hold the digits of the number, and the rest say how big it is. With 53 bits you can count every whole number up to 2 to the power of 53, which is 9,007,199,254,740,992.
Past that point, the gap between the numbers JavaScript can store becomes 2, then 4, then 8, and keeps doubling. A whole number that falls between two marks is rounded to the nearest one. Sixteen nines is past the limit, and the nearest mark is 10000000000000000.
console.log(Number.MAX_SAFE_INTEGER); // prints: 9007199254740991
console.log(Number.isSafeInteger(9007199254740991)); // prints: true
console.log(Number.isSafeInteger(9999999999999999)); // prints: false
Number.MAX_SAFE_INTEGER is the biggest safe whole number (integer just means whole number). Up to it, every whole number has its own mark, and adding or subtracting them gives exact answers. Number.isSafeInteger tells you whether a value is still inside that range.
How to break the curse with BigInt
Add an n to the end of a whole number and it becomes a BigInt, a separate type that grows as big as it needs to and stays exact.
console.log(9999999999999999n); // prints: 9999999999999999n
console.log(9999999999999999n + 1n); // prints: 10000000000000000n
console.log(BigInt("123456789012345678901")); // prints: 123456789012345678901n
console.log(typeof 5n); // prints: bigint
You can also make one from text with BigInt("..."), which is handy for big IDs that arrive as strings. Don't pass a huge plain number to BigInt, because the rounding has already happened before BigInt sees it.
BigInts and normal numbers don't mix in math.
const big = 10n;
console.log(big + 1);
That throws a TypeError. Write big + 1n, or Number(big) + 1 if the value is small enough. BigInts only hold whole numbers, so 7n / 2n is 3n.
Bonus twist: max + 1 === max + 2
const max = Number.MAX_SAFE_INTEGER;
console.log(max + 1); // prints: 9007199254740992
console.log(max + 2); // prints: 9007199254740992
console.log(max + 1 === max + 2); // prints: true
Right after the safe limit, the marks are 2 apart. max + 2 should be 9007199254740993, but that number has no mark, so it rounds onto the same value as max + 1.
Real code runs into this with database IDs, social media post IDs and timestamps in nanoseconds. If a server sends a huge ID as a JSON number, JSON.parse rounds it before you ever see it. That is why many APIs send IDs as strings.
Rule of thumb
Working with really big whole numbers, like IDs? Use BigInt, or keep them as text.
Storing IDs as numbers. An ID is a label, not something you do math with, so keep it as a string and you can never lose a digit.
Check yourself
It is 9007199254740991. Below it every whole number is exact, but past it the gaps between stored numbers grow and values get rounded.