CS Foundations

Floating Point: Why 0.1 + 0.2 Is Not 0.3

IEEE 754 doubles with bit-level demos, rounding, safe integers, comparing floats and handling money.

Lesson 0216 min read#floating-point#ieee-754#numbers

On this page

  1. Words used in this lesson
  2. What is inside a double
  3. Why 0.1 is not exact
  4. Rounding, and how big the gaps are
  5. Comparing floats
  6. Safe integers
  7. NaN, Infinity and negative zero
  8. toFixed and rounding to decimals
  9. Money
  10. Other surprises worth knowing
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: JavaScript numbers are 64-bit binary floating point (IEEE 754 doubles). They store a sign, an exponent and 52 bits of fraction, so many decimal numbers such as 0.1 can only be stored as the nearest binary value. That is why 0.1 + 0.2 is 0.30000000000000004. The fix is not a trick: compare with a tolerance, and keep money in whole cents (integers), BigInt or a decimal library.

Every programmer meets 0.1 + 0.2 !== 0.3 once. Interviewers ask about it to see whether you know why, and what you do about it in real code (prices, totals, percentages). A good answer has three parts: the cause (binary fractions), the limits (about 15 to 17 significant digits, integers exact up to 2^53), and the practice (integers for money, tolerance for comparing).

This lesson shows the actual bits of a double, so the explanation is something you can see and not just memorise. It builds on Lesson 0, where typed arrays showed that memory is just bytes. The next lesson, Lesson 4, covers binary, endianness and bit tricks in more depth.

What I ran / checked. Every code block was run in Node v22.22.0 and the output is quoted. For toFixed I read the MDN page for Number.prototype.toFixed (raw Markdown from the mdn/content repository on GitHub), and I compared its examples with my own runs. I tried to read the ECMAScript spec source on GitHub, but the file path I used returned 404, so claims about the spec below are from MDN and from what Node printed, not from the spec text. The decimal library test used decimal.js 10.4.3 from npm, installed in a scratch folder. The Number.MAX_SAFE_INTEGER and Number.EPSILON values are printed by Node, not copied from memory.

Words used in this lesson

  • A bit is 0 or 1. A byte is 8 bits.
  • Binary (base 2) writes numbers with only 0 and 1. In binary, each place is worth twice the place to its right: 1, 2, 4, 8 on the left of the point and 1/2, 1/4, 1/8 on the right.
  • Floating point stores a number like scientific notation: a few significant digits plus a power. The "point" can float to a different place. Example in decimal: 12300 is 1.23 x 10^4.
  • IEEE 754 is the standard that defines the common floating point formats. JavaScript's Number uses its 64-bit format, called binary64 or double.
  • Sign, exponent, mantissa are the three parts of a double. The mantissa (also called the significand or the fraction) holds the digits. The exponent is the power of 2.
  • Precision is how many significant digits a format can keep. Rounding is choosing the nearest representable number when an exact one does not exist.
  • Epsilon (Number.EPSILON) is the gap between 1 and the next double above 1.
  • A safe integer is an integer that a double can store exactly, and whose neighbours are also exact. In JavaScript: from -(2^53 - 1) to 2^53 - 1.
  • NaN (Not a Number) is the special value for an undefined numeric result, such as 0 / 0.
  • BigInt is a separate JavaScript type for whole numbers of any size.

What is inside a double

A double uses 64 bits, split into three fields:

FieldBitsJob
Sign10 means positive, 1 means negative
Exponent11The power of 2, stored with an offset (bias) of 1023
Mantissa (fraction)52The digits after an implied leading 1

For a normal number, the value is: (-1)^sign x 1.mantissa x 2^(exponent - 1023). The leading 1. is not stored, because it is always there (so you get 53 bits of precision for 52 stored bits).

I wrote a small bit-dump function. DataView lets me write a double into 8 bytes and read the same 8 bytes back as an integer:

JavaScript
function bits(x) {  const dv = new DataView(new ArrayBuffer(8));  dv.setFloat64(0, x);  const n = dv.getBigUint64(0);  const b = n.toString(2).padStart(64, "0");  return { sign: b[0], exponent: b.slice(1, 12), mantissa: b.slice(12), hex: "0x" + n.toString(16).padStart(16, "0") };}function show(x) {  const { sign, exponent, mantissa, hex } = bits(x);  const e = parseInt(exponent, 2);  const note = e === 0 ? "zero/subnormal" : e === 2047 ? "inf/nan" : "2^" + (e - 1023);  console.log((Object.is(x, -0) ? "-0" : String(x)).padEnd(20), sign, exponent, mantissa, hex, note);}for (const x of [1, 2, 0.5, -1, 0.1, 0.2, 0.3, 0.1 + 0.2, 3, 0, -0, Infinity, NaN, 5e-324]) show(x);

Output (columns: value, sign, exponent, mantissa, hex, power of 2):

TEXT
1                    0 01111111111 0000000000000000000000000000000000000000000000000000 0x3ff0000000000000 2^02                    0 10000000000 0000000000000000000000000000000000000000000000000000 0x4000000000000000 2^10.5                  0 01111111110 0000000000000000000000000000000000000000000000000000 0x3fe0000000000000 2^-1-1                   1 01111111111 0000000000000000000000000000000000000000000000000000 0xbff0000000000000 2^00.1                  0 01111111011 1001100110011001100110011001100110011001100110011010 0x3fb999999999999a 2^-40.2                  0 01111111100 1001100110011001100110011001100110011001100110011010 0x3fc999999999999a 2^-30.3                  0 01111111101 0011001100110011001100110011001100110011001100110011 0x3fd3333333333333 2^-20.30000000000000004  0 01111111101 0011001100110011001100110011001100110011001100110100 0x3fd3333333333334 2^-23                    0 10000000000 1000000000000000000000000000000000000000000000000000 0x4008000000000000 2^10                    0 00000000000 0000000000000000000000000000000000000000000000000000 0x0000000000000000 zero/subnormal-0                   1 00000000000 0000000000000000000000000000000000000000000000000000 0x8000000000000000 zero/subnormalInfinity             0 11111111111 0000000000000000000000000000000000000000000000000000 0x7ff0000000000000 inf/nanNaN                  0 11111111111 1000000000000000000000000000000000000000000000000000 0x7ff8000000000000 inf/nan5e-324               0 00000000000 0000000000000000000000000000000000000000000000000001 0x0000000000000001 zero/subnormal

How to read it:

  • 1: exponent 01111111111 is 1023, so the power is 2^0, mantissa is all zeros, value 1.0 x 1.
  • 2 has exponent 1024 (power 2^1); 0.5 has exponent 1022 (power 2^-1). Powers of two are exact, with an all-zero mantissa.
  • 3 is 1.1 in binary (that is 1.5) times 2^1, so the mantissa starts with a single 1.
  • -1 differs from 1 only in the sign bit. The hex shows it: bff... against 3ff....
  • 0 and -0 are two different bit patterns. Object.is(0, -0) is false, while 0 === -0 is true (I show this later).
  • Infinity has the exponent bits all 1 and a zero mantissa. NaN has the exponent bits all 1 and a non-zero mantissa.
  • 5e-324 is Number.MIN_VALUE, the smallest positive double: one bit set in the lowest place. Numbers with an all-zero exponent field are "subnormal", a special form that gets close to zero smoothly.

Why 0.1 is not exact

Look at the mantissa for 0.1: 1001 1001 1001 ... repeating. In binary, 0.1 is a repeating fraction, in the same way that 1/3 is 0.333... in decimal. A repeating fraction does not fit in 52 bits, so it is cut and rounded, and the last digits became ...1010 (the rounding turned 1001 into 1010). The stored number is slightly larger than 0.1.

A fraction can be written exactly in binary only if its denominator, in lowest terms, is a power of 2: 1/2, 1/4, 3/8 are fine. 1/10 has a factor of 5 in the denominator, so it can never be exact. I printed the exact values that the doubles hold:

JavaScript
console.log(0.1 + 0.2, 0.1 + 0.2 === 0.3);console.log((0.1).toPrecision(25), (0.2).toPrecision(25), (0.3).toPrecision(25));console.log((0.1+0.2).toPrecision(25));
TEXT
0.30000000000000004 false0.1000000000000000055511151 0.2000000000000000111022302 0.29999999999999998889776980.3000000000000000444089210

So 0.1 is really 0.1000000000000000055..., 0.2 is 0.2000000000000000111..., and their exact sum is 0.3000000000000000166..., which falls exactly half-way between two neighbouring doubles (0.29999999999999998... and 0.3000000000000000444...). A tie rounds to the neighbour with an even last bit, which is the upper one (bits ...0100). But the double nearest to the decimal 0.3 is 0.2999999999999999888... (bits ...0011). They are different neighbours, so === says false. Nothing is broken: the addition was exact up to a final rounding. The error is in what the literals 0.1 and 0.2 stand for.

Rounding, and how big the gaps are

Doubles are not evenly spaced. There are the same number of them (2^52) between each pair of powers of two. So the gap grows with the size of the number. I computed the gap to the next double using the bit pattern plus one:

JavaScript
function ulp(x){ const dv=new DataView(new ArrayBuffer(8)); dv.setFloat64(0,x); dv.setBigUint64(0,dv.getBigUint64(0)+1n); return dv.getFloat64(0)-x; }for (const x of [1, 1000, 1e6, 2**53, 1e300]) console.log("spacing at", x, "=", ulp(x));
TEXT
spacing at 1 = 2.220446049250313e-16spacing at 1000 = 1.1368683772161603e-13spacing at 1000000 = 1.1641532182693481e-10spacing at 9007199254740992 = 2spacing at 1e+300 = 1.487016908477783e+284

(ULP means "unit in the last place", the gap between neighbours.) Two things follow. First, the relative error is always about 2^-53, so a double keeps about 15 to 17 significant decimal digits. Second, around 9 quadrillion the gap becomes 2, so odd integers no longer exist.

IEEE 754 rounds to the nearest representable value, and breaks exact ties toward the even mantissa ("round half to even"). This is the rounding of + - * / and square root. Errors from each operation are tiny but they add up:

JavaScript
let s = 0; for (let i = 0; i < 10; i++) s += 0.1; console.log(s, s === 1);let t = 0; for (let i = 0; i < 1e6; i++) t += 0.1; console.log(t);
TEXT
0.9999999999999999 false100000.00000133288

Ten additions of 0.1 do not give 1. A million give an error in the 6th decimal place. Order also matters (addition is not associative):

JavaScript
console.log([0.1,0.2,0.3].reduce((a,b)=>a+b), [0.3,0.2,0.1].reduce((a,b)=>a+b));
TEXT
0.6000000000000001 0.6

Same numbers, different order, different answer. If you need a more accurate sum, there are compensated algorithms such as Kahan summation. I ran this Kahan sum over a million copies of 0.1:

JavaScript
function kahan(a){ let sum=0,c=0; for(const x of a){ const y=x-c; const t=sum+y; c=(t-sum)-y; sum=t;} return sum; }const arr = new Array(1e6).fill(0.1);console.log(kahan(arr), arr.reduce((a,b)=>a+b,0));
TEXT
100000 100000.00000133288

The compensated sum is exact here (100000); the plain reduce is not. For most web code, the better step is not to sum floats at all (see money below).

Comparing floats

Never compare computed floats with ===. Use a tolerance. Number.EPSILON is the gap above 1, and it is exactly 2^-52:

JavaScript
console.log("diff", (0.1 + 0.2) - 0.3, "EPSILON", Number.EPSILON, 2**-52 === Number.EPSILON);const close = (a, b, eps = Number.EPSILON) => Math.abs(a - b) <= eps * Math.max(1, Math.abs(a), Math.abs(b));console.log(close(0.1 + 0.2, 0.3), close(1e10 + 0.1 + 0.2, 1e10 + 0.3), 1e10+0.1+0.2 === 1e10+0.3);
TEXT
diff 5.551115123125783e-17 EPSILON 2.220446049250313e-16 truetrue true false

The difference between the two sides of 0.1 + 0.2 === 0.3 is 5.55e-17, smaller than Number.EPSILON. A good close function scales the tolerance with the size of the numbers (relative tolerance). The plain Math.abs(a - b) < Number.EPSILON is correct only for numbers close to 1: for 1e10 the gaps are much bigger than EPSILON, so the check would fail even for numbers that differ by one ULP. My close multiplies by the larger magnitude, and returned true for the 1e10 pair where === said false. For results that went through many operations, a tolerance of a few ULPs (or something like 1e-9 relative) is often more realistic than exactly one EPSILON. Pick the tolerance based on the problem and write it down. In tests, use your test library's close-to function.

Safe integers

Doubles store whole numbers exactly up to 2^53. Past that, whole numbers start to be skipped:

JavaScript
console.log("MAX_SAFE", Number.MAX_SAFE_INTEGER, 2**53 - 1, "MIN_SAFE", Number.MIN_SAFE_INTEGER);console.log(2**53, 2**53 + 1, 2**53 + 2, 9007199254740993, Number.isSafeInteger(2**53));
TEXT
MAX_SAFE 9007199254740991 9007199254740991 MIN_SAFE -90071992547409919007199254740992 9007199254740992 9007199254740994 9007199254740992 false

2**53 + 1 is the same as 2**53: there is no double there. The literal 9007199254740993 silently became 9007199254740992. This bites with 64-bit database IDs, Twitter-style snowflake IDs and large counters: JSON numbers above 2^53 are damaged when parsed into a Number. Send such IDs as strings, or use BigInt. Number.isSafeInteger(x) tells you if an integer is in the safe range.

The ends of the range:

JavaScript
console.log(Number.MAX_VALUE, Number.MIN_VALUE, Number.MAX_VALUE * 2, -Number.MAX_VALUE * 2);
TEXT
1.7976931348623157e+308 5e-324 Infinity -Infinity

Too big becomes Infinity (overflow). Too small becomes 0 (underflow, after the subnormal range).

NaN, Infinity and negative zero

JavaScript
console.log(NaN === NaN, Object.is(NaN, NaN), [NaN].includes(NaN), [NaN].indexOf(NaN), Number.isNaN("abc"), isNaN("abc"));console.log(0 / 0, 1 / 0, -1 / 0, Math.sqrt(-1), parseInt("x"), Number(""), Number(undefined), Number(null), +"12px");console.log(Infinity - Infinity, Infinity * 0, 1 / Infinity, Number.isFinite(1/0), 0 === -0, Object.is(0, -0), 1/-0, JSON.stringify({a:NaN,b:Infinity,c:-0}));console.log(typeof NaN, Math.max(), Math.min(), Math.max(1, NaN), [3, NaN, 1].sort((a,b)=>a-b));
TEXT
false true true -1 false trueNaN Infinity -Infinity NaN NaN 0 NaN 0 NaNNaN NaN 0 false true false -Infinity {"a":null,"b":null,"c":0}number -Infinity Infinity NaN [ 3, NaN, 1 ]

What each line teaches:

  • NaN === NaN is false. NaN is the only value that is not equal to itself. Use Number.isNaN(x) or Object.is(x, NaN). includes finds NaN but indexOf does not (-1), because they compare differently.
  • isNaN("abc") is true because the global isNaN converts its argument to a number first. Number.isNaN("abc") is false because "abc" is not the number NaN. Prefer Number.isNaN.
  • Number("") is 0 and Number(null) is 0 but Number(undefined) is NaN. +"12px" is NaN, while parseInt("12px") would give 12.
  • Dividing by zero gives Infinity, not an error, as 1 / 0. And 1 / -0 is -Infinity, which is the one place where 0 and -0 behave differently.
  • NaN spreads: any arithmetic with NaN gives NaN. It then hides in totals until it shows up as "NaN" on a page.
  • JSON.stringify turns NaN, Infinity and -0 into null, null and 0. The data changes silently.
  • Math.max() with no arguments is -Infinity, which is a handy "identity" for the max of an empty list.
  • [3, NaN, 1].sort((a, b) => a - b) stayed [3, NaN, 1], which is not sorted. The comparator returns NaN, which the sort treats as "equal". Remove NaN first, or handle it in the comparator.

toFixed and rounding to decimals

toFixed(n) returns a string with n digits after the point. It rounds the exact value of the double, which is why some results look wrong:

JavaScript
console.log((1.005).toFixed(2), (1.005 * 100), Math.round(1.005 * 100) / 100, (1.255).toFixed(2), (8.345).toFixed(2), (0.5).toFixed(0), (1.5).toFixed(0), (2.5).toFixed(0), (-1.5).toFixed(0));console.log((1.005).toPrecision(20), (8.345).toPrecision(20), (1.255).toPrecision(20));console.log(typeof (0.1).toFixed(2), (0.1).toFixed(20), (1e21).toFixed(2), (123.456).toFixed(1));console.log(Math.round(-0.5), Math.round(2.5), Math.round(-2.5), Math.round(0.49999999999999994));
TEXT
1.00 100.49999999999999 1 1.25 8.35 1 2 3 -21.0049999999999998934 8.3450000000000006395 1.2549999999999998934string 0.10000000000000000555 1e+21 123.5-0 3 -2 0
  • (1.005).toFixed(2) is "1.00", not "1.01". The stored value is 1.00499999999999989..., so it is correctly below the half-way point. The same rounding error is why 1.005 * 100 is 100.49999999999999 and Math.round(1.005 * 100) / 100 gives 1.
  • (8.345).toFixed(2) is "8.35" because the stored value is 8.3450000000000006..., above the half-way point. The two results look inconsistent only if you think in decimal. MDN has the same kind of example: (2.45).toFixed(1) rounds up and (2.55).toFixed(1) rounds down because of how the doubles are stored.
  • toFixed rounds exact ties away from zero in these runs: (0.5).toFixed(0) is "1", (2.5).toFixed(0) is "3", (-1.5).toFixed(0) is "-2". That is different from the "round half to even" used by IEEE arithmetic and by many accounting rules. Only ties that are exactly representable (like 2.5) are real ties.
  • Math.round rounds ties toward positive infinity: Math.round(-2.5) is -2 while Math.round(2.5) is 3. And Math.round(-0.5) is -0.
  • Math.round(0.49999999999999994) is 0. This number is just below 0.5, a case where a naive Math.floor(x + 0.5) would give 1.
  • For numbers from 1e21 and above, toFixed returns exponent notation: (1e21).toFixed(2) is "1e+21".
  • The result is a string. Do not add toFixed outputs by mistake ("0.10" + "0.20" is "0.100.20"). Use it at the very end, for display only, never to "fix" a value that you will compute with again. If you must round a number, +(0.1 + 0.2).toFixed(2) gives 0.3, but it does not fix the cause.

Money

Do not store prices as floats. Look at what happens with a small basket:

JavaScript
const prices = [19.99, 5.01, 0.1, 0.2];console.log("float sum:", prices.reduce((a, b) => a + b, 0));const cents = prices.map(p => Math.round(p * 100));console.log("cents:", cents, "sum cents:", cents.reduce((a, b) => a + b, 0));
TEXT
float sum: 25.3cents: [ 1999, 501, 10, 20 ] sum cents: 2530

Here the float sum looked fine by luck (25.3). The risk is that it is only luck. Three safe designs:

  1. Integers in the smallest unit (cents, pence, paise). All adds and subtracts are exact as long as the total is a safe integer. This is the most common production choice. Convert to a display string only at the edge. Know the currency's decimal places: JPY has 0, USD has 2, KWD has 3. Intl.NumberFormat knows them.
  2. BigInt when amounts can exceed 2^53 (for example, minor units of a crypto token or a very large ledger).
  3. A decimal library (decimal.js, big.js, bignumber.js) when you need decimal fractions of cents, tax rates, or arbitrary precision.

Formatting with Intl.NumberFormat:

JavaScript
const fmt = c => (c < 0 ? "-" : "") + Math.floor(Math.abs(c) / 100) + "." + String(Math.abs(c) % 100).padStart(2, "0");console.log(fmt(25), fmt(5), fmt(-1999));console.log(new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(2531 / 100));console.log(new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5));console.log(new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" }).format(1234), new Intl.NumberFormat("en", {style:"currency",currency:"KWD"}).format(1.2345));
TEXT
0.25 0.05 -19.99$25.311.234,50 €¥1,234 KWD 1.235

Note that 2531 / 100 is a float again, but only at the display step, and the formatter rounds it.

Splitting money: do not lose a cent

Dividing 100 cents among 3 people with floats gives 33.33333333333333 each. With integers you must decide who gets the extra cent:

JavaScript
const split = (total, n) => { const base = Math.floor(total / n), rem = total % n; return Array.from({ length: n }, (_, i) => base + (i < rem ? 1 : 0)); };console.log(split(100, 3), split(100, 3).reduce((a, b) => a + b));console.log(100/3*3, (100/3).toFixed(2)*3);
TEXT
[ 34, 33, 33 ] 100100 99.99

The shares add back to exactly 100. Rounding each share to 33.33 would have lost a cent (99.99). The rule: allocate the remainder, do not round away each share.

BigInt

BigInt holds integers of any size exactly. It never has fraction digits: division truncates toward zero.

JavaScript
console.log(2n ** 64n, 9007199254740993n, BigInt(2**53) + 1n, 10n / 3n, -7n / 2n, 7n % 3n, typeof 1n);try { 1n + 1 } catch (e) { console.log(e.constructor.name + ": " + e.message) }try { Math.sqrt(4n) } catch (e) { console.log(e.constructor.name + ": " + e.message) }try { JSON.stringify({a:1n}) } catch (e) { console.log(e.constructor.name + ": " + e.message) }console.log(Number(2n**64n), 1n == 1, 1n === 1, 2n > 1);try { BigInt(0.5) } catch (e) { console.log(e.constructor.name + ": " + e.message) }
TEXT
18446744073709551616n 9007199254740993n 9007199254740993n 3n -3n 1n bigintTypeError: Cannot mix BigInt and other types, use explicit conversionsTypeError: Cannot convert a BigInt value to a numberTypeError: Do not know how to serialize a BigInt18446744073709552000 true false true

The Number(2n**64n) value, 18446744073709552000, has lost its low digits. Key facts: no mixing with Number in arithmetic, Math.* does not take BigInt, JSON.stringify does not know BigInt (send it as a string), 1n == 1 is true but 1n === 1 is false (different types), and BigInt(0.5) is a RangeError.

A decimal library

JavaScript
const Decimal = require("decimal.js");console.log(new Decimal("0.1").plus("0.2").toString(), new Decimal("0.1").plus("0.2").equals("0.3"), new Decimal(0.1).plus(0.2).toString());console.log(new Decimal("1.005").toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toString(), new Decimal("2.5").toDecimalPlaces(0, Decimal.ROUND_HALF_EVEN).toString(), new Decimal(1).div(3).toString());
TEXT
0.3 true 0.31.01 2 0.33333333333333333333

With strings as input, decimal arithmetic works the way people expect: 0.1 + 0.2 equals 0.3, 1.005 rounds half up to 1.01, and you choose the rounding rule (ROUND_HALF_EVEN is "banker's rounding": 2.5 goes to 2). new Decimal(0.1) also printed 0.3 here, because the library reads a number through its shortest string "0.1". Still, build decimals from strings, since a float you computed earlier may already hold an error. The cost: slower than native numbers, and a dependency. (Note: this is a library from npm and not built in to the language. A built-in decimal type is a proposal, and I did not check its status, so I do not rely on it.)

Other surprises worth knowing

JavaScript
console.log(0.1 + 0.7, (0.1+0.7)*10, Math.floor((0.1+0.7)*10), parseInt(0.0000005), parseInt("0.0000005"));console.log(Math.fround(0.1), Math.fround(0.1) === 0.1);
TEXT
0.7999999999999999 7.999999999999999 7 5 00.10000000149011612 false
  • Math.floor((0.1 + 0.7) * 10) gives 7, not 8. Truncating after float math is a common bug (for example, Math.floor(price * 100)). Use Math.round when converting to cents.
  • parseInt(0.0000005) is 5 because the number is first converted to the string "5e-7", and parseInt reads the 5. Do not use parseInt to truncate numbers; use Math.trunc.
  • Math.fround rounds a value to 32-bit float. The 32-bit 0.1 is 0.10000000149011612. Typed arrays Float32Array store this. Use 32-bit floats only if memory matters more than precision (graphics, machine learning).

Interview questions

Why is 0.1 + 0.2 !== 0.3 in JavaScript? (Must know)

JavaScript numbers are binary doubles. 0.1 and 0.2 are repeating fractions in binary, so they are stored as the nearest double, which is slightly off: 0.1 is stored as 0.1000000000000000055 and 0.2 as 0.2000000000000000111. Their sum rounds to 0.30000000000000004, a different double from the one nearest to the literal 0.3. It is not a JavaScript bug; Python, Java and C do the same with their doubles.

What are the three parts of a double? (Must know)

One sign bit, 11 exponent bits (power of 2 with a bias of 1023), and 52 mantissa bits (digits after an implied leading 1). The value is (-1)^sign x 1.mantissa x 2^(exponent - 1023). I can show it: 1 is 0 01111111111 000...0, and -1 only differs in the sign bit.

How do you compare two floats? (Must know)

Not with ===. I use a tolerance: Math.abs(a - b) <= eps * Math.max(1, Math.abs(a), Math.abs(b)). A fixed absolute epsilon is only right near 1, because the gap between doubles grows with the size of the number. I choose eps to fit the problem, maybe Number.EPSILON times a few, or 1e-9. If I can, I avoid the problem by using integers.

What is Number.EPSILON? (Must know)

The gap between 1 and the next double, which is 2^-52 (about 2.22e-16). It tells you the precision near 1. It does not apply to bigger numbers: near 1e10 the gap is much larger.

How do you handle money? (Must know)

I keep amounts as integers in the smallest unit (cents) and convert only for display, with Intl.NumberFormat. I round with Math.round once, at the moment I convert in, and I allocate the remainder when splitting so the parts add up. For very large values I use BigInt. When I need fractions of a cent or tax calculations with rules for rounding, I use a decimal library built from strings, with an explicit rounding mode. I store the currency code with the amount.

What is the largest safe integer, and what goes wrong beyond it? (Must know)

2^53 - 1, which is 9007199254740991 (Number.MAX_SAFE_INTEGER). Past 2^53 the gap between doubles is 2, so some integers cannot be stored: 2**53 + 1 === 2**53. This corrupts big IDs when JSON is parsed to numbers. I use strings or BigInt for IDs above that, and Number.isSafeInteger to check.

Why does (1.005).toFixed(2) return "1.00"? (Trap)

Because 1.005 is stored as 1.00499999999999989..., which is below half-way, so it rounds down. toFixed is right about the stored value. To get "1.01" I either use a decimal library with a rounding mode on the string "1.005", or work in integers from the start. I would not use Math.round(x * 100) / 100, because 1.005 * 100 is 100.49999999999999.

What is NaN and how do you test for it? (Must know)

It is the result of an undefined numeric operation, like 0/0 or Number("abc"). It is not equal to itself, so NaN === NaN is false. I use Number.isNaN(x) (not the global isNaN, which converts first and says true for "abc") or Object.is(x, NaN). NaN also spreads through arithmetic, so I validate input early.

What is the difference between 0 and -0? (Trap)

They are two different bit patterns (sign bit set for -0). 0 === -0 is true, but Object.is(0, -0) is false, and 1 / -0 is -Infinity. JSON.stringify(-0) gives "0", so the sign is lost. It rarely matters, but it shows up with Math.round(-0.5) and in some tests that use Object.is or toEqual.

Is addition of floats associative? (Senior signal)

No. [0.1, 0.2, 0.3] summed left to right gives 0.6000000000000001, while [0.3, 0.2, 0.1] gives 0.6. Each step rounds, and the rounding depends on the size of the partial sum. That is why parallel sums, reordered sums or a different sort order can change a result in the last digits. For long sums I would use integers or a compensated sum (Kahan).

When would you use BigInt, and what are its limits? (Senior signal)

For exact whole numbers beyond 2^53: large IDs, factorials, cryptography, minor units of big balances. Limits: integers only (division truncates), cannot mix with Number in arithmetic, no Math functions, no JSON.stringify without a custom conversion, and it is slower than Number.

Math.floor((0.1 + 0.7) 10) is not 8. Why? (Trap)

Float arithmetic can land just below the exact decimal result, and Math.floor then cuts it down. In my run, Math.floor((0.1 + 0.7) * 10) gave 7, not 8. If the value is meant to be a whole number after scaling, use Math.round, or use integers from the start.

Why does sorting [3, NaN, 1] with (a, b) => a - b not work? (Trap)

The comparator returns NaN when one side is NaN, and the sort treats that as "equal", so the result is not ordered ([3, NaN, 1] in my test). I filter NaN out first, or write a comparator that puts NaN at one end on purpose.

Key takeaways

  • A Number is a 64-bit double: 1 sign bit, 11 exponent bits, 52 mantissa bits. You can see them with DataView.
  • Fractions with a denominator that is not a power of 2 (like 1/10) cannot be exact in binary. 0.1 + 0.2 is 0.30000000000000004 because of that.
  • Gaps between doubles grow with the size of the number. Integers are exact up to 2^53 - 1 (Number.MAX_SAFE_INTEGER).
  • Compare with a relative tolerance, not ===. Use Number.isNaN for NaN.
  • toFixed returns a string and rounds the stored value, so (1.005).toFixed(2) is "1.00". Use it for display only.
  • Money: integers in cents, BigInt for huge values, or a decimal library built from strings. Split by allocating the remainder.
  • Send IDs that may pass 2^53 as strings. JSON.stringify cannot handle BigInt, and turns NaN and Infinity into null.
  • Addition is not associative; order changes the last digits.

What's next

Lesson 3: Unicode, UTF-8 and Strings That Lie. Numbers are one place where "what you see" is not "what is stored". Text is the other. Next we look at why "😀".length is 2, and how a string becomes bytes.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series