You know that Array.prototype.reduce you've used a thousand times to sum numbers? It's lying to you.
1. A One-Liner That Should Work
const nums = Array.from({ length: 1000 }, () => 0.1);
const sum = nums.reduce((a, b) => a + b, 0);
console.log(sum); // 99.9999999999986
console.log(sum === 100); // false (!)
A thousand tenths should equal one hundred. But in JavaScript — and in virtually every other mainstream language — it doesn't. Not exactly.
If you've ever shrugged off a 0.0000000000001 discrepancy, this article is for you. Because at scale, that "tiny" error isn't tiny at all.
2. The Problem: Why Floats Betray Us
JavaScript uses IEEE 754 double-precision floating-point for all numbers. In plain English: your numbers are stored as binary fractions, and most decimal fractions (like 0.1) have no exact binary representation.
When you add 0.1 + 0.2, the computer isn't adding "one-tenth plus two-tenths." It's adding the closest binary approximations it can store. The result? 0.30000000000000004.
Now multiply that imperfection across a thousand, a million, or a billion additions. The error doesn't stay small — it accumulates.
The Core Issue
When you sum left-to-right with a running total, you eventually reach a point where the running total is huge and the next number is tiny. In floating-point arithmetic, adding a tiny number to a huge number often results in no change at all — the tiny number gets "swallowed."
Imagine pouring water into a bucket with a small hole. Each pour loses a few drops. After 1,000 pours, you're missing a puddle.
3. The Fix: Kahan Summation
In 1965, William Kahan published an algorithm that tracks and recovers the low-order bits lost in each addition. The trick? A compensation variable that remembers what got rounded away.
The Intuition
Instead of just keeping a running total, Kahan summation keeps two values:
-
sum— the best approximation so far -
c(compensation) — the error that was discarded in the last step
Before each new addition, the compensation from the previous step is added back in. After the addition, the new error is computed and stored for the next iteration.
The Algorithm
Input: x
│
▼
x + c ◄── compensation from last step
│
▼
t = sum + (x + c)
│
▼
c = (t - sum) - (x + c) ← the "recovered" error
│
▼
sum = t
JavaScript Implementation
function kahanSum(numbers) {
let sum = 0;
let c = 0; // compensation for lost low-order bits
for (let i = 0; i < numbers.length; i++) {
const y = numbers[i] - c; // subtract previous error
const t = sum + y; // tentative sum
c = (t - sum) - y; // new error: (what we added) - (what we meant to add)
sum = t;
}
return sum;
}
Line-by-Line Breakdown
| Line | What It Does |
|---|---|
y = numbers[i] - c |
Add back the error from the previous step |
t = sum + y |
Perform the actual addition |
c = (t - sum) - y |
Calculate what got lost in rounding |
sum = t |
Commit the new sum |
The magic is in (t - sum) - y. Because of how floating-point subtraction works, this expression recovers the bits that were rounded away during sum + y.
4. Live Comparison: Naive vs. Kahan
Let's run both algorithms on the same pathological dataset:
// 10 million copies of 0.0001
// True answer: exactly 1000
const nums = Array.from({ length: 10_000_000 }, () => 0.0001);
console.time('naive');
const naive = nums.reduce((a, b) => a + b, 0);
console.timeEnd('naive');
console.time('kahan');
const kahan = kahanSum(nums);
console.timeEnd('kahan');
console.log('Naive: ', naive); // 999.9998999965879
console.log('Kahan: ', kahan); // 1000.0000000000001
The naive sum is off by 0.0001. That might not sound like much, but in financial systems processing millions of transactions, "small" errors compound into real money.
Kahan's error? 1.1e-13 — effectively zero for all practical purposes.
5. The Middle Ground: Pairwise Summation
Before we crown Kahan the winner, there's a third contender that often gets overlooked: pairwise summation (also called cascade summation).
The idea is beautifully simple: instead of accumulating left-to-right, recursively split the array in half, sum each half, then add the two results. This ensures you're always adding numbers of roughly the same magnitude.
The Intuition
Imagine balancing a scale. Naive summation keeps piling weights on one side until the running total dwarfs every new addition. Pairwise summation weighs equal-sized groups against each other at every step.
[1, 2, 3, 4, 5, 6, 7, 8]
/ \
[1,2,3,4] [5,6,7,8]
/ \ / \
[1,2] [3,4] [5,6] [7,8]
| | | |
3 7 11 15
\ / \ /
10 26
\ /
36
Recursive Implementation
function pairwiseSum(arr, start = 0, end = arr.length) {
const len = end - start;
if (len === 0) return 0;
if (len === 1) return arr[start];
const mid = start + Math.floor(len / 2);
return pairwiseSum(arr, start, mid) + pairwiseSum(arr, mid, end);
}
⚠️ Caution: For very large arrays (> ~10,000 items), the recursive version will blow the call stack. Here's a stack-safe iterative version:
🔧 Stack-safe iterative version
function pairwiseSumIterative(arr) {
if (arr.length === 0) return 0;
let queue = arr.slice();
while (queue.length > 1) {
const next = [];
for (let i = 0; i < queue.length; i += 2) {
next.push(queue[i] + (queue[i + 1] || 0));
}
queue = next;
}
return queue[0];
}
6. The Heavyweight: decimal.js
Sometimes you need exact decimal arithmetic, not just "less error." That's where decimal.js comes in. It represents numbers as strings internally and performs arbitrary-precision math.
Installation
npm install decimal.js
Usage
const Decimal = require('decimal.js');
function decimalSum(numbers) {
return numbers
.reduce((sum, n) => sum.plus(new Decimal(n.toString())), new Decimal(0))
.toNumber();
}
💡 Pro tip: Always pass numbers through
.toString()before constructing aDecimal.new Decimal(0.1)still inherits floating-point imprecision because0.1is already corrupted by the time it reaches the constructor.
7. The Showdown: Four-Way Comparison
Let's put them all to the test with a pathological dataset:
// 10 million copies of 0.0001
// True answer: exactly 1000
const nums = Array.from({ length: 10_000_000 }, () => 0.0001);
| Method | Result | Error | Relative Speed |
|---|---|---|---|
Naive reduce |
999.9998999965879 |
~1.0e-4 |
1.0x (baseline) |
| Pairwise | 999.9999999999123 |
~8.8e-11 |
~1.5x slower |
| Kahan | 1000.0000000000001 |
~1.1e-13 |
~3.5x slower |
| decimal.js |
1000 (exact) |
0 |
~40-80x slower |
What the Numbers Tell Us
- Naive is fast but wrong at scale. The error is small in absolute terms, but in financial systems, "small" errors compound into real money.
-
Pairwise dramatically reduces error with minimal overhead. Its error grows logarithmically
O(log n)instead of linearly. -
Kahan achieves near-constant error
O(ε)— the gold standard for floating-point summation. - decimal.js is exact but slow. It's not really a summation algorithm; it's a different number system entirely.
8. The Decision Framework
Here's a quick cheat sheet for choosing the right tool:
| Scenario | Recommendation | Why |
|---|---|---|
| Small arrays (< 1,000 items) | Naive reduce |
Error is negligible; don't over-engineer |
| Large arrays, need speed, "good enough" accuracy | Pairwise | Logarithmic error growth, parallelizable |
| Financial/scientific computing, must minimize error | Kahan | Constant error bound, no extra dependencies |
| Accounting, currency, must be exact | decimal.js | Arbitrary precision eliminates rounding entirely |
| GPU/Web Worker parallel processing | Pairwise | Perfectly parallelizable; Kahan is inherently sequential |
The Trade-off Triangle
Speed
/\
/ \
/ \
/ \
/ ⚠️ \
/__________\
Accuracy Simplicity
You can pick two:
- Fast + Simple → Naive
- Fast + Accurate → Pairwise
- Accurate + Simple → Kahan
- Exact → decimal.js (sacrifices speed)
9. When NOT to Use Kahan
Kahan summation is great, but it's not always the right call:
-
Integers only — If your array contains only integers representable in 53 bits, naive summation is exact until you exceed
Number.MAX_SAFE_INTEGER. -
Already using a decimal library — If you've bought into
decimal.jsorbig.js, you don't need Kahan; you're already exact. - Real-time graphics/animation — A 3-4x slowdown for a pixel-perfect particle position is rarely worth it.
-
Very small datasets — The error on
[0.1, 0.2, 0.3]is5.55e-17. Your users don't care.
10. One-Liners for Your Toolkit
For the copy-paste engineers:
// Kahan (functional, but slightly slower due to array destructuring)
const kahanSum = (a) => a.reduce(([s, c], x) => {
const y = x - c;
const t = s + y;
return [t, (t - s) - y];
}, [0, 0])[0];
// Pairwise (iterative, stack-safe)
const pairwiseSum = (a) => {
let q = [...a];
while (q.length > 1) {
const n = [];
for (let i = 0; i < q.length; i += 2) n.push(q[i] + (q[i+1] || 0));
q = n;
}
return q[0] || 0;
};
Conclusion
Floating-point summation is one of those problems that seems trivial until it isn't. The good news: JavaScript gives you options.
- Reach for pairwise summation when you need a quick accuracy boost without a performance cliff.
- Reach for Kahan summation when precision is paramount and you're working with raw floats.
- Reach for decimal.js when you're dealing with money, tax calculations, or anything where "close enough" isn't in your vocabulary.
And sometimes? Just use reduce. Not every array needs a PhD.
What's your go-to strategy for numerical accuracy in JavaScript? Drop your war stories in the comments below. 👇
Top comments (1)
Never use floating point values for financial stuff. Integers - always. No library required.