Beyond Code & Karma · JavaScript Internals
The number you see is not the number that is stored.
0.1 + 0.2 is 0.30000000000000004 — not a bug, but
the price of squeezing the infinite real line into 64 bits. Sign, exponent,
mantissa, and the illusion of exactness.
Chapter 01 — The Foundation
We write numbers in base ten, where each fractional place is a power of ten —
0.1 is one tenth. A computer stores numbers in base two, where each
fractional place is a power of two: a half, a quarter, an eighth. Some
decimals land neatly there (0.5 is exactly 2⁻¹), but
one tenth never does. In binary, 0.1 is
0.0001100110011… repeating forever — just like 1/3 is
0.333… forever in decimal. A double has only 52 mantissa bits, so
the pattern is cut off and rounded to the nearest value it can hold.
The Cosmic Connection
In Vedic thought, maya is the
veil of appearance: the world the senses report is not the world as it truly
is — satya (the real) lies
beneath the rendered form. Floating point is maya made mechanical. The
0.3 printed on screen is rupa — a name the eye accepts —
while the bits in memory hold something a hair apart. The rounding step is the
veil itself: the act that turns an infinite binary truth into a finite,
believable appearance.
(0.1).toString() returns "0.1" — the friendly form the senses accept. Appearance, not essence.0.10000000000000001 — the value that truly is, beneath the name.
"To write (0.1 + 0.2) === 0.3 is to trust the veil. To compare
within an epsilon is to look past the form to the truth beneath it."
Chapter 02 — Show Me The Bits
IEEE-754 is scientific notation in base two: a sign, a fraction, and an exponent. The whole standard fits in one formula —
value = (−1)sign × 1.mantissa × 2(exponent − 1023)
JavaScript never exposes a number's bits directly, but a DataView
over an ArrayBuffer lets you write a double and read its raw 64
bits back — native precision, no libraries:
// The CPU keeps a number as raw bits — DataView reads them, no libs. const buf = new ArrayBuffer(8); const dv = new DataView(buf); dv.setFloat64(0, 0.1); // write the double into memory const raw = dv.getBigUint64(0); // read the 64 raw bits back raw.toString(16); // "3fb999999999999a"
Shift and mask the three fields out, then rebuild the value. This is exactly what the visualizer below animates as you toggle bits:
// Pull the three fields out with shifts and masks. const sign = Number((raw >> 63n) & 1n); // 1 bit const exp = Number((raw >> 52n) & 0x7ffn); // 11 bits const mant = raw & 0xfffffffffffffn; // 52 bits const value = (sign ? -1 : 1) * (1 + Number(mant) / 2 ** 52) // implied leading 1.f * 2 ** (exp - 1023); // un-bias the exponent
The top bit. 0 → positive, 1 → negative. Multiply the result by (−1)^sign.
Read 11 bits as an unsigned integer, then subtract the 1023 bias to get the true power of two — the scale.
Read 52 mantissa bits as a fraction and prepend the implied leading 1 → a value in [1, 2) called 1.f.
Multiply: (−1)^s × 1.f × 2^(e−1023). The exact decimal that the 64 bits truly represent.
| Format | Total | Exponent | Mantissa | Bias | ~Decimal digits |
|---|---|---|---|---|---|
| half (float16) | 16 | 5 bits | 10 bits | 15 | ~3–4 |
| float (float32) | 32 | 8 bits | 23 bits | 127 | ~7 |
| double (float64) | 64 | 11 bits | 52 bits | 1023 | ~15–17 |
JavaScript numbers are always float64.
The 32-bit mode in the visualizer is for contrast — it's what
Math.fround and Float32Array use, and where GPU
shaders and ML weights often live.
Chapter 03 — Live Visualization
Press reveal. Each row shows what JavaScript prints, the 17-digit truth, and the
actual 64 bits — sign in gold, exponent in saffron, mantissa in cyan. Compare
the last bits of 0.1 + 0.2 against 0.3: they differ by
one.
Chapter 04 — Deep Dive
This is where the 10,000 basic videos stop. Here's what actually bites in production.
Three roundings stack. 0.1 rounds slightly high.
0.2 rounds slightly high. Their exact sum doesn't land
on a representable double either, so the result rounds too — and it rounds
to the double one bit above the stored 0.3. That single
ULP (unit in the last place) gap is the whole
0.00000000000000004. Every floating point operation can nudge
the result, and across a loop the nudges accumulate.
(0.1 + 0.2) === 0.3 is false. Compare within a
tolerance scaled to the magnitude of the operands — a fixed
Number.EPSILON only works near 1.0:
0.1 + 0.2 === 0.3; // false 0.1 + 0.2; // 0.30000000000000004 // Compare within a tolerance instead of === const close = (a, b) => Math.abs(a - b) <= Number.EPSILON * Math.max(Math.abs(a), Math.abs(b)); close(0.1 + 0.2, 0.3); // true
A balance in a float will drift. Accumulate thousands of
+ 0.01 operations and the rounding error becomes a visible
discrepancy on a statement. The fix is to never let currency live in a
float at all — store the smallest unit as an integer:
// Store paise, not rupees — integers never drift. const price = 1999; // ₹19.99 as paise const total = price * 3; // 5997 paise, exact const display = (total / 100).toFixed(2); // "59.97"
Integers are exact up to Number.MAX_SAFE_INTEGER
(2⁵³ − 1); beyond that, reach for BigInt.
The exponent field reserves its extremes. When every exponent bit is
zero, the implied leading 1 disappears — these are
subnormals, filling the gap between the smallest normal
number and zero. When every exponent bit is one, a zero mantissa
means ±Infinity and a non-zero mantissa means
NaN. That's why NaN !== NaN: many bit patterns
are NaN, and the standard refuses to call any of them equal.
IEEE-754 is the substrate under almost every number you compute with:
| Domain | How floats bite |
|---|---|
| Currency / finance | Drift in accumulated sums — the canonical reason to use integer minor units or decimals. |
| Graphics / GPU | 32-bit floats everywhere; precision loss shows as z-fighting and jittery far-away geometry. |
| Machine learning | float32/float16/bfloat16 weights; quantization trades mantissa bits for speed and memory. |
| Geospatial | Large coordinates lose precision in the mantissa — metres of error far from the origin. |
Chapter 05 — Dharma & The Float
Karma is the principle that every action carries a consequence, deferred to
the right time. In floating point the action is
rounding — each operation that
cannot be represented exactly leaves a tiny residue, an unpaid debt of
precision. The debt is invisible at first; it comes due only when the errors
accumulate across a loop, or when you compare with ===.
// The name you see… (0.1).toString(); // "0.1" ← the rendered illusion (maya) // …is not the value that is. (0.1).toPrecision(17); // "0.10000000000000001" ← the stored truth
The name the senses accept ("0.1") is not the value that is
(0.10000000000000001). To mistake the rendered form for the truth
is to be bound by maya; to compare within an epsilon — to allow for the residue
every action leaves — is to act with awareness of consequence. The float holds
no grudge and rounds without malice; it simply keeps the nearest truth it can,
and lets the rest go.
"Do not cling to the exactness of the form. Account for the residue, and the arithmetic stays honest." — the Gita, interpreted for numerical analysts 🙏
Chapter 06 — Test Yourself
Three questions. No cheating. Your karma is watching.
The Enlightenment
कर्म करो, फल की चिंता मत करो