JavaScript's Number.toFixed Rounding Trap: The Invoice That Undercharges by a Penny

2026-07-11

This function rounds each line item on an invoice to two decimal places, then sums the rounded amounts. Every input in the test case ends in .005, so you'd expect every item to round up to .01 and the total to be 6.03. Instead, QA files a bug: the invoice shows 6.01, and two of the three line items round down.

function computeInvoice(items) {
  let total = 0;
  const lines = items.map(item => {
    const rounded = Number(item.amount.toFixed(2));
    total += rounded;
    return { name: item.name, amount: rounded };
  });
  return { lines, total: Number(total.toFixed(2)) };
}

const items = [
  { name: "A", amount: 1.005 },
  { name: "B", amount: 2.005 },
  { name: "C", amount: 3.005 },
];

console.log(computeInvoice(items));
// Expected: A=1.01, B=2.01, C=3.01, total=6.03
// Actual:   A=1.00, B=2.00, C=3.01, total=6.01

The Bug

Number.toFixed(2) doesn't round the literal 1.005 — it rounds whatever 1.005 is actually stored as in IEEE-754 double precision, which is almost never exactly 1.005.

Half your items round the "wrong" way, the total drifts from the sum of the printed lines, and accountants notice within a day. Worse, the pattern isn't intuitive: (0.15).toFixed(1) returns "0.1" (stores as 0.1499...), but (0.35).toFixed(1) returns "0.4" (stores as 0.35000000000000003). It's a pseudo-random distribution driven entirely by which decimals happen to be exactly representable in binary.

On top of that, browsers and Node used to disagree on ties, and the ECMAScript spec allows implementations to pick either of two adjacent representable results. So even code that "works on my machine" may silently break in a different runtime version.

The Fix

Two workable strategies. First — and the one every finance team eventually settles on — store money as integer cents and only format for display:

function computeInvoice(items) {
  let totalCents = 0;
  const lines = items.map(item => {
    const cents = Math.round(item.amountCents);
    totalCents += cents;
    return { name: item.name, amount: (cents / 100).toFixed(2) };
  });
  return { lines, total: (totalCents / 100).toFixed(2) };
}

Second, if you can't rewrite the data model, round with an epsilon nudge before Math.round sees the value:

function roundHalfUp(n, digits) {
  const shift = 10 ** digits;
  return Math.round((n + Number.EPSILON) * shift) / shift;
}

The Number.EPSILON addition pushes values that "should" be exactly at a .5 boundary just above it, so half-up rounding produces the arithmetic answer humans expect. It's imperfect at extreme magnitudes (near Number.MAX_SAFE_INTEGER, epsilon is smaller than one ulp of your value), but it fixes the invoice.

Better still: reach for decimal.js, big.js, or the emerging Decimal proposal for anything that will ever be summed and audited. .toFixed() is a display function that superficially looks like a math function, and every product that treats it as the latter eventually ships a bug that costs someone real money.

Key Takeaway: .toFixed() rounds the IEEE-754 approximation of your number, not the number you wrote — for money, use integer cents or a decimal library, never binary floats plus .toFixed().

All newsletters