Every JavaScript developer has seen 0.1 + 0.2 print 0.30000000000000004, usually in a meme. Fewer can explain why, and fewer still have noticed the second precision problem, which causes worse bugs: integers above about nine quadrillion silently change. A 19-digit order id from a Java or Go backend comes out of JSON.parse as a different number, and the user sees “order not found”.
This guide explains both problems from the representation up, then covers the tools: integer minor units for money, BigInt for large integers, and the newer JSON parsing hook that keeps big ids intact.
One number type, stored in binary
JavaScript’s Number is an IEEE 754 double: 64 bits split into a sign, an 11-bit exponent and a 52-bit fraction. Every number you write, 3, 0.1 or 1e21, is stored that way. There’s no separate integer type (apart from BigInt, which comes later).
Binary fractions can represent halves, quarters, eighths and sums of those exactly. They can’t represent a tenth, for the same reason decimal can’t represent a third: the expansion repeats forever. So the literal 0.1 is stored as the nearest double, which is slightly more than a tenth:
console.log((0.1).toFixed(20)); // 0.10000000000000000555
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(1.1 + 2.2); // 3.3000000000000003The sum of two slightly-too-large approximations rounds to a double that isn’t the double nearest to 0.3. JavaScript prints the shortest decimal string that uniquely identifies the stored value, so you see the error. Python, Java and C# doubles give exactly the same results. This isn’t a JavaScript bug; it’s how binary floating point works everywhere.
Comparing floats
Because of this, never compare computed floats with ===. Compare with a tolerance that fits your data. Number.EPSILON (about 2.22e-16) is the gap between 1 and the next double, so Math.abs(a - b) < Number.EPSILON works for values near 1:
console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON); // trueBut that tolerance is too tight for large values (the gap between doubles grows with magnitude) and meaningless for others. For measurements, pick a tolerance in the units you care about: within a millimetre, within a cent. Interviewers like hearing that Number.EPSILON isn’t a universal answer.
Money: count the cents
The safe way to handle currency in plain JavaScript is to store integer minor units: 1999 cents, not 19.99 dollars. Integer addition is exact (up to a limit we’ll get to), so totals, taxes per line and refunds add up correctly. You only divide by 100 at the very end, for display.
The step everybody gets wrong is converting a decimal input to cents:
console.log(4.35 * 100); // 434.99999999999994
console.log(Math.floor(4.35 * 100)); // 434
console.log(Math.round(4.35 * 100)); // 4354.35 is stored as a hair less than 4.35, so multiplying by 100 gives a hair less than 435. Math.floor or Math.trunc (or parseInt, or | 0) chops it to 434 and you’ve lost a cent. Math.round gives 435. Better still, parse the string the user typed by splitting on the decimal point, so the float never exists.
toFixed has the same issue in the other direction. (1.005).toFixed(2) returns '1.00', not '1.01', because 1.005 is actually stored as 1.00499999999999989342. Use Intl.NumberFormat for display and do your rounding on integer cents:
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
console.log(fmt.format(2501 / 100)); // $25.01Not every currency has two decimal places: the Japanese yen has none and the Kuwaiti dinar has three. Store the currency code next to the amount and look up its minor-unit exponent rather than hard-coding 100. For tax, interest or currency conversion, where you need defined rounding modes on fractional results, a decimal library (decimal.js or big.js) is the honest answer.
Integers stop being exact after 2^53
A double has 53 bits of precision (52 stored, plus an implicit leading 1). Every integer up to 2^53 fits exactly. Past that, the gaps between representable numbers become 2, then 4, then 8, and odd numbers can’t be stored at all:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(2 ** 53 + 1); // 9007199254740992
console.log(2 ** 53 === 2 ** 53 + 1); // true
console.log(Number.isSafeInteger(2 ** 53)); // falseMAX_SAFE_INTEGER is 2^53 - 1, the largest integer where n and n + 1 are both exactly representable. “Safe” means arithmetic on it can’t produce an answer that silently equals a neighbour.
This rarely matters for counting things. It matters a lot for identifiers. Twitter’s snowflake ids, Discord ids and many database BIGINT keys are 64-bit integers, well past the safe range. When a backend sends one as a JSON number, JSON.parse turns it into the nearest double:
console.log(JSON.parse('{"id": 1234567890123456789}').id);
// 1234567890123456800The last digits changed, and nothing threw. Your frontend now requests order 1234567890123456800, which doesn’t exist. The fix that works everywhere is on the server: send large ids as strings, as the Twitter API does with its id_str fields. Ids aren’t quantities, so you never need to do arithmetic on them anyway.
BigInt for exact integers of any size
BigInt, added in ES2020, is a second numeric type for arbitrary-precision integers. You write it with an n suffix or create it with BigInt(...) from an integer or a string:
console.log(9007199254740993n, typeof 10n); // 9007199254740993n bigint
console.log(BigInt(Number.MAX_SAFE_INTEGER) + 2n); // 9007199254740993n
console.log(BigInt('1234567890123456789')); // 1234567890123456789nPass big values as strings. BigInt(12345678901234567890) would convert an already-rounded Number, so the damage is done before BigInt sees it.
The type has deliberate restrictions, and each one produces an error you should recognise:
1n + 1; // TypeError: Cannot mix BigInt and other types, use explicit conversions
BigInt(1.5); // RangeError: The number 1.5 cannot be converted to a BigInt because it is not an integer
Math.max(1n); // TypeError: Cannot convert a BigInt value to a number
JSON.stringify({ a: 1n }); // TypeError: Do not know how to serialize a BigIntNo mixing with Number is intentional: an automatic conversion would either lose precision (BigInt to Number) or throw (fractional Number to BigInt), so the language makes you choose. Comparisons are allowed: 1n == 1 is true, 2n > 1 is true, while 1n === 1 is false because the types differ. The Math methods only accept Numbers.
Division truncates toward zero: 7n / 2n is 3n and -7n / 2n is -3n. That’s handy for splitting an amount, with the remainder from % telling you how many units to distribute: splitting 1000n cents three ways gives 333n each with 1n left over.
BigInt is also slower than Number for ordinary arithmetic, since it can’t use the CPU’s floating-point hardware directly. Use it where you need exactness beyond 2^53, not as a general replacement.
Reading big ids from JSON
If you can’t change the server, newer engines let a JSON.parse reviver see the original source text of each value. Node 22 supports it:
const data = JSON.parse('{"id": 1234567890123456789}',
(key, value, ctx) => key === 'id' ? BigInt(ctx.source) : value);
console.log(data); // { id: 1234567890123456789n }The third ctx argument isn’t available in every browser yet, so check your support targets. For serialising, either convert BigInts to strings yourself or give a replacer function to JSON.stringify.
Practice question: the invoice that’s off by a cent
Question. An invoice page parses each line’s price from an input with parseFloat, multiplies by quantity, sums the lines and shows total.toFixed(2). Finance reports totals that are occasionally one cent off the backend’s figure. What’s going on, and how would you restructure it?
Answer. Every price like 4.35 or 1.005 is stored as a nearby binary double, so each multiplication and addition carries a tiny error. Most of the time toFixed(2) hides it, but when a true total lands on a half cent, the stored value can sit just below the half and round down where the backend (probably using a decimal type) rounds up. The fix is to stop doing money in floats: parse the input string straight into integer cents (split on the decimal point, or Math.round(parseFloat(s) * 100) as a minimum), multiply cents by integer quantities, sum integers, and format with Intl.NumberFormat only for display. If per-line tax or discount percentages produce fractional cents, decide on one rounding rule (per line or on the total, and half-up versus half-even) and make it match the backend exactly; at that point a decimal library is worth adding. A strong answer also suggests a test with the exact failing invoice, since floating-point bugs are easy to reintroduce.