A JavaScript reporting job sums millions of floating-point amounts, and its totals disagree with the source ledger by small amounts that change when the rows are processed in a different order. How do you diagnose this and decide on a fix?
answer
- addition is not associative
- every partial sum rounds
- big totals swallow small addends
- order changed, so the total changed
- exactness is a domain decision
basics
~20 sFloating-point addition is not associative: each partial sum is rounded, so a different order gives a different total, and large running sums absorb small addends entirely. Diagnose by comparing against an exact integer sum, then decide whether the domain needs exactness or bounded error.
solid answer
~50 sOrder dependence is the signature of accumulated rounding: every partial sum is rounded to the nearest double, so `(a + b) + c` and `a + (b + c)` can genuinely differ, and once the running total is large it absorbs small addends completely — adding a thousand values of 1e-9 to 1e9 changes nothing at all, while adding them first does. To diagnose, re-run the same input in sorted and shuffled order and compare against an exact reference sum computed in integer minor units; that separates rounding drift from a real data or filtering bug. Then the decision is about the domain, not the algorithm. If the values are money or counts, the fix is representation — sum integers and the drift disappears entirely. If they are genuinely continuous measurements, reduce the error with compensated (Kahan or Neumaier) or pairwise summation, and define the reconciliation tolerance explicitly. Shrinking the error and eliminating it are different commitments; pick one deliberately.
code
javascript · 22 linesconst vals = [1e9, ...Array(1000).fill(1e-9)];
// Large first: every small addend is absorbed and vanishes
console.log(vals.reduce((a, b) => a + b, 0) - 1e9); // 0
// Small first: the same values survive
console.log([...vals].reverse().reduce((a, b) => a + b, 0) - 1e9); // ~9.5e-7
function kahanSum(xs) {
let sum = 0, c = 0;
for (const x of xs) {
const y = x - c;
const t = sum + y;
c = (t - sum) - y;
sum = t;
}
return sum;
}
const tenths = Array(10).fill(0.1);
console.log(tenths.reduce((a, b) => a + b, 0)); // 0.9999999999999999
console.log(kahanSum(tenths)); // 1go deeper
Know that adding many floating-point values accumulates rounding error, so a sum of a million 0.01 values is not exactly 10000, and that the order of addition can change the result.
Explain the mechanism: each partial sum is rounded to the nearest double, so addition is not associative, and a large running total absorbs addends too small to change its last bit.
Drive the diagnosis — reproduce over a frozen batch, compare orders against an exact integer reference, characterise the magnitude spread — and choose between exact integer accumulation and compensated or pairwise summation on the evidence rather than by reflex.
Own the policy: decide which quantities in the system are exact by representation and which carry a documented relative tolerance, fix a single aggregation of record so components never sum the same rows in different orders and compare for equality, and treat a widened tolerance as a defect rather than a fix.
## The symptom names the cause A total that changes with input order is close to a proof that the drift is floating-point accumulation rather than a logic bug. Real logic bugs — a bad filter, a duplicated row, a timezone boundary — usually shift the total by a recognisable amount and do not care about ordering. Rounding drift changes by tiny quantities that move when the order does. ## Why order matters Floating-point addition is commutative but *not associative*. Each `+` computes the exact sum of its two operands and then rounds that to the nearest double. Do it in a different order and you round different intermediate values: ```js const xs = Array(1e6).fill(0.01); xs.reduce((a, b) => a + b, 0); // 10000.000000171856, not 10000 ``` A million tiny roundings, each well under an epsilon, add up to a visible discrepancy. Note that this is not engine nondeterminism — the specification fixes the operation and the evaluation order, so the same order always yields the same total. It is the *choice* of order, made by your pipeline, that varies. ## Absorption is the sharper effect The more dramatic version is absorption (or swamping): when the running total is much larger than the next addend, the exact sum rounds straight back to the running total and the addend vanishes. ```js const vals = [1e9, ...Array(1000).fill(1e-9)]; vals.reduce((a, b) => a + b, 0) - 1e9; // 0 - all lost [...vals].reverse().reduce((a, b) => a + b, 0) - 1e9; // ~9.5e-7 - retained ``` Accumulating the small values first and the large one last preserves them; the other order discards a thousand additions entirely. This is why the same dataset reconciles when it arrives sorted and fails when it arrives by insertion order. ## Diagnosing it properly 1. **Freeze an input.** Capture one batch and make the job reproducible over it. 2. **Compute an exact reference.** Sum the same values as integer minor units, where addition cannot round. That total is the truth to compare against. 3. **Vary only the order.** Sorted ascending, sorted descending, shuffled. If the totals move and all sit near the exact reference, it is rounding. If one order is off by a whole unit or a whole row's worth, look for a data bug first. 4. **Characterise the magnitude spread.** Absorption needs a wide dynamic range; a million similar-sized values drift gently, whereas a few huge values among many tiny ones lose whole populations of addends. 5. **Check the reconciliation rule.** Often the real defect is that two systems compare totals for exact equality across representations that were never guaranteed to agree. ## The options, and what each one commits you to - **Exact integer accumulation.** Sum minor units. The drift does not shrink, it ceases to exist, and the total is order-independent and reproducible. This is the right answer whenever the domain is discrete — currency, counts, quantities with a fixed scale. It costs a representation change at the boundaries. - **Compensated summation (Kahan, or Neumaier for wide ranges).** Keep a second variable holding the error lost in the last addition and feed it back into the next one. Cheap, local, and dramatic: ```js function kahanSum(xs) { let sum = 0, c = 0; for (const x of xs) { const y = x - c; const t = sum + y; c = (t - sum) - y; sum = t; } return sum; } ``` It reduces error to near a single rounding, but it is still approximate and still order-sensitive in principle. - **Pairwise summation.** Add recursively in halves so error grows like log(n) rather than n. Naturally suited to chunked or parallel aggregation. - **Sorting by magnitude before summing.** Cheap to describe, expensive at scale, and it only mitigates absorption. - **Moving the aggregation to a component with exact decimal arithmetic.** A legitimate architectural answer when the data already lives there, at the cost of splitting where business rules run. ## The decision is about the domain The question a principal engineer actually answers is not "which summation algorithm" but "does this number have to be exact". A ledger total that must reconcile to the cent, feed an audit, or match a partner's statement is a discrete quantity and should never have been a float — the drift is a symptom of a representation mistake, and compensated summation would merely hide it below the current threshold until volume grows. A p95 latency, a sensor average, a forecast input is genuinely continuous; there exactness is meaningless and the right output is a documented error bound. So settle three things and write them down: which quantities in the system are exact by representation; what tolerance the approximate ones reconcile within, expressed relatively rather than as a fixed absolute; and where the aggregation of record happens, so two components never independently sum the same rows in different orders and then compare for equality. Make it a property of the platform rather than a fix applied to one report, or the same investigation recurs in the next quarter's dashboard.
- Is the changing total evidence that the JavaScript engine reorders the additions?No. The specification fixes both IEEE-754 semantics and evaluation order, so a given sequence of additions always produces the identical double in every engine. The variation comes from your pipeline feeding rows in different orders — partition order, sort order, concurrency — not from the engine optimising arithmetic.
- What does compensated (Kahan) summation actually do?It keeps a running compensation variable holding the low-order part lost in the previous addition, subtracts it from the next addend, and recomputes the loss each step. That recovers most of the discarded bits, keeping total error near a single rounding instead of growing with the element count. Neumaier's variant additionally handles addends larger than the running sum.
- When is compensated summation the wrong fix?When the domain is discrete. For money or counts, exact integer accumulation removes the error rather than shrinking it, and Kahan summation only pushes the discrepancy below today's reconciliation threshold — it reappears as volume or dynamic range grows. Use compensation for genuinely continuous measurements where an exact answer has no meaning.
saying these in an interview costs you the question
- Blames engine nondeterminism or CPU differences
- Says rounding each addend with toFixed fixes the total
- Assumes floating-point addition is associative
- Treats Kahan summation as making the total exact
- Widens the reconciliation tolerance until the alert stops