Why JavaScript Floating Point Math Breaks Your App (And How to Fix It)

If you’ve written any kind of financial or precision-sensitive logic in JavaScript, you’ve likely hit this:

0.1 + 0.2 === 0.3 // false

0.1 + 0.2 // 0.30000000000000004

It looks like a bug. It isn’t. It’s a consequence of how JavaScript represents numbers — and understanding why it happens is the first step to writing code that doesn’t silently produce wrong results.


Why JavaScript Numbers Are Imprecise

JavaScript uses the IEEE 754 double-precision floating point format for all numbers. This means every number is stored in 64 bits: 1 for sign, 11 for exponent, and 52 for the fractional part (the mantissa).

The problem is that not all decimal fractions can be represented exactly in binary. 0.1 in base 10 is an infinitely repeating fraction in base 2 — similar to how 1/3 is 0.333... in base 10. The CPU has to truncate it somewhere, and that’s where the error creeps in.

This isn’t a JavaScript quirk. Java, Python, Ruby, and most modern languages share the same behavior. The difference is that JavaScript doesn’t have a native Decimal or BigDecimal type in its standard library, which makes the problem more visible.


Where This Actually Bites You

The raw 0.1 + 0.2 case looks obvious. The real danger is subtler — values that look fine in isolation but drift when composed:

const price = 8.5; const fee = price * 0.2;

console.log(fee); // 1.7000000000000002 console.log(fee === 1.7); // false

Or when you’re rounding and the floating point artifact crosses a threshold:

Math.ceil(1.7000000000000002 * 100) / 100 // 1.71, not 1.70

That last example is the kind of bug that makes it into production. The calculation looks correct, the individual values look correct, but the composed result is wrong by one cent.


Solution 1: toFixed() for Display, Not Logic

A common first instinct is toFixed():

(0.1 + 0.2).toFixed(2) // "0.30"

This works for display, but it returns a string. Using it in further calculations just pushes the problem forward:

parseFloat((0.1 + 0.2).toFixed(2)) + 0.01 // 0.31 — fine here, but fragile

Reserve toFixed() for formatting output. Don’t rely on it for intermediate computation.


Solution 2: Integer Arithmetic (Work in Cents)

The most reliable approach for money is to work in the smallest unit — cents — using integers.

const priceInCents = 850; // 8.50 AED const feeInCents = Math.ceil(priceInCents * 0.2); // 170 → 1.70 AED

const display = feeInCents / 100; // back to AED for output

Since integers are represented exactly in IEEE 754 (up to 2^53 - 1), you eliminate rounding errors in the critical path. Convert to the lowest denomination on input, do all math in integers, convert back on output.

The tradeoff: you need to be disciplined about when conversion happens. A single stray division that produces a float and re-enters your pipeline will reintroduce the problem.


Solution 3: Scale Up, Round Out the Noise, Then Ceiling

Sometimes you’re working with values that were already computed as floats — maybe from a third-party API or a formula you don’t fully control. In those cases, the trick is to neutralise the floating point noise before applying any rounding direction.

Here’s a pattern I use for ceiling-to-2-decimal-places:

function ceilTo2(value) { return Math.ceil(Math.round(value * 10000) / 100) / 100; }

ceilTo2(1.7000000000000002) // 1.70 (not 1.71) ceilTo2(10.011) // 10.02 ceilTo2(10.015) // 10.02 ceilTo2(10.01) // 10.01

What’s happening here:

  1. value * 10000 — scale up to eliminate sub-cent precision loss. 1.7000000000000002 * 10000 = 17000.000000000002.
  2. Math.round(...) — snap to the nearest integer. The floating point error (2e-13) is far smaller than 0.5, so 17000.000000000002 rounds cleanly to 17000. A genuine third decimal digit like 10.011 * 10000 = 100110.0 stays at 100110.
  3. / 100 — back to cents-level precision. 17000 / 100 = 170100110 / 100 = 1001.1.
  4. Math.ceil(...) — ceiling on the cents value. 170 stays 1701001.1 becomes 1002.
  5. / 100 — final conversion back to the display unit.

The key insight: floating point noise lives at around 1e-13 scale; real sub-cent values live at 0.001 or larger. The Math.round(value * 10000) step cleanly separates them.


Solution 4: decimal.js or big.js for Complex Use Cases

If your domain involves a lot of chained calculations — tax on top of discount on top of platform fee — arbitrary precision libraries remove the mental overhead entirely:

import Decimal from 'decimal.js';

const price = new Decimal('8.5'); const fee = price.times('0.2');

fee.toString() // "1.7" fee.equals(new Decimal('1.7')) // true

The tradeoff is bundle size and the cognitive overhead of using a wrapper type throughout your codebase. For pure backend work or complex financial logic, it’s often worth it. For a single rounding function, it’s overkill.


Which Approach Should You Use?

ScenarioRecommended approach
Formatting a number for displaytoFixed()
Storing and computing monetary valuesInteger arithmetic (cents)
Rounding float outputs from formulasScale-round-ceil pattern
Complex chained financial calculationsdecimal.js / big.js

My default is integer arithmetic for anything money-related. It’s the most boring and the most correct. The scale-round-ceil pattern is a pragmatic fallback when you’re dealing with computed floats you can’t avoid.


The Takeaway

Floating point errors in JavaScript aren’t random or unpredictable — they’re deterministic artifacts of a well-defined standard. Once you understand that, the solutions become obvious: either avoid floats in the critical path entirely, or account for the error explicitly when you have to work with them.

The danger isn’t the 0.1 + 0.2 case that every developer has seen. It’s the Math.ceil(1.7000000000000002 * 100) / 100 = 1.71 case that slips through code review and ends up charging a customer one extra cent.


Discover more from The Dev World – Sergio Lema

Subscribe to get the latest posts sent to your email.


Comments

Leave a comment