Build a loan amortization table in JavaScript and you'll often get one that looks right until someone adds up a column. The interest column totals $327.96, the summary row says $327.97, and the balance ends at $0.05 instead of zero.
None of that is a bug in the loan formula. It's a rounding problem, and it comes from four places: how numbers are stored, how they're printed, which rounding rule you use, and when you round. Every output below is from actually running this code on Node 24. The loop snippets are trimmed slightly for length.
1. The decimal you typed isn't the number you got
JavaScript numbers are IEEE 754 double-precision binary floats. Most decimal fractions, including most cent amounts, can't be stored exactly, so you get the nearest representable value:
console.log(1.005 * 100); // 100.49999999999999
console.log(Math.round(1.005 * 100) / 100); // 1
console.log((1.005).toFixed(2)); // "1.00"
console.log((1.005).toPrecision(20)); // "1.0049999999999998934"
So "round to cents" rounds 1.005 down, because the stored value really is a hair below 1.005. MDN's toFixed page has the same trap with (2.55).toFixed(1) returning "2.5".
A common patch is to add Number.EPSILON before rounding. It helps for small numbers and quietly stops working as the numbers get bigger, because EPSILON is tiny compared to the gap between floats at that size:
const nudge = (x) => Math.round((x + Number.EPSILON) * 100) / 100;
console.log(nudge(1.005), nudge(10000.005)); // 1.01 10000
One more surprise: Math.round sends exact halves toward +∞, not away from zero.
console.log(Math.round(2.5), Math.round(-2.5)); // 3 -2
That matters as soon as refunds or negative adjustments show up.
2. Intl.NumberFormat is for display, and it rounds its own way
Intl.NumberFormat is the right tool for turning a number into $1,234.57. Just keep in mind that it rounds too, and it doesn't have to agree with toFixed:
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
console.log(usd.format(1.005), (1.005).toFixed(2)); // $1.01 1.00
console.log(usd.resolvedOptions().roundingMode); // halfExpand
Same value, two built-ins, two answers. Per MDN, the default roundingMode is "halfExpand" (ties away from zero), and you can ask for others, including "halfEven":
const usdEven = new Intl.NumberFormat('en-US',
{ style: 'currency', currency: 'USD', roundingMode: 'halfEven' });
console.log(usd.format(0.125), usdEven.format(0.125)); // $0.13 $0.12
console.log(usd.format(0.135), usdEven.format(0.135)); // $0.14 $0.14
The fix for both sections is the same: don't do money math on floats, and don't let a formatter decide your amounts. Store integer cents and only format at the edge:
const fromCents = (c) => usd.format(c / 100);
console.log(fromCents(123456789)); // $1,234,567.89
Integers are exact in a Number up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991). That's about $90 trillion in cents. Use BigInt if you multiply cents by rates and want the intermediate products exact too.
3. Half-up vs. banker's rounding
When the exact result lands precisely on half a cent, you need a rule. Here's one helper that rounds an exact fraction of two BigInts either way:
function divRound(num, den, mode = 'halfUp') { // BigInt, den > 0
const sign = num < 0n ? -1n : 1n;
const a = num < 0n ? -num : num;
let q = a / den;
const r2 = (a % den) * 2n;
if (r2 > den || (r2 === den && (mode === 'halfUp' || q % 2n === 1n))) q += 1n;
return sign * q; // halfUp = half away from zero
}
Round ten amounts that are all exactly x.5 cents (0.5¢, 1.5¢ … 9.5¢, which add up to 50¢):
halfUp cents: 1,2,3,4,5,6,7,8,9,10 = 55n
halfEven cents: 0,2,2,4,4,6,6,8,8,10 = 50n
Half-up always pushes ties the same way, so over many ties it drifts high. Half-even, also called banker's rounding, alternates and cancels out on average. Neither one is "correct." What matters is which one your contract, regulator or downstream system expects. Pick one, name it in code, and use it everywhere. Also note divRound(-25n, 10n) gives -3n, while Math.round(-2.5) gives -2.
4. The loan schedule: drift vs. the cents fix
Take $10,000 at 6% APR over 12 monthly payments. The standard formula gives a payment of $860.66 after rounding to cents.
Naive version: keep the balance as a float, round only when printing.
let bal = 10000, totalInt = 0, shownInt = 0, shownPrin = 0;
for (let k = 1; k <= 12; k++) {
const interest = bal * 0.005;
const prin = 860.66 - interest;
bal -= prin;
totalInt += interest;
shownInt += Number(interest.toFixed(2));
shownPrin += Number(prin.toFixed(2));
}
naive: interest column adds to 327.96 | total row shows 327.97
naive: principal column adds to 9999.96 | balance left 0.05
Three bugs in one table. Each printed row is rounded, but the running balance isn't, so the columns don't add up to the totals. And because the payment was rounded down from $860.6643, the loan never quite reaches zero.
Cents version: every amount is an integer number of cents. Interest is rounded once a month with a named rule, and the last payment absorbs whatever is left:
let b = 1000000n, payC = 86066n;
for (let k = 1; k <= 12; k++) {
const interest = divRound(b * 600n, 12n * 10000n, 'halfEven'); // 6.00% = 600 bps
const prin = k === 12 ? b : payC - interest;
b -= prin;
// record interest, prin, interest + prin, b
}
1 $860.66 $50.00 $810.66 $9,189.34
2 $860.66 $45.95 $814.71 $8,374.63
11 $860.66 $8.54 $852.12 $856.42
12 $860.70 $4.28 $856.42 $0.00
cents: interest $327.96 | principal $10,000.00 | balance $0.00
Every row now adds up, the columns match the totals, and the balance hits exactly zero. The 4-cent difference shows up where it belongs: a final payment of $860.70. That last-payment adjustment is common in real amortization schedules, and a printed table should show it rather than hide it.
Checklist
- Store money as integer cents (or BigInt), never as float dollars.
- Round at defined points, such as once per period, with a named rule (
halfUporhalfEven). - Carry the rounded balance forward, so the table is self-consistent.
- Let the final payment absorb the remainder.
- Use
Intl.NumberFormatonly for display, and setroundingModeexplicitly if it matters.
References
- IEEE 754-2019, Standard for Floating-Point Arithmetic: https://standards.ieee.org/ieee/754/6210/
- MDN, Number.prototype.toFixed(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed
- MDN, Intl.NumberFormat() constructor (
roundingMode): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat - MDN, Math.round(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
- MDN, Number.MAX_SAFE_INTEGER: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER
AI disclosure: this post was drafted with AI assistance. All code was run on Node 24, and the outputs shown are copied from that run.
Disclosure: I'm the maker of FINORA, a set of free browser-based finance calculators. No affiliate links, and this is about code, not financial advice. FINORA's loan calculator: https://finance.wooooju.com/en/calculator/personal-loan/
Top comments (0)