toFixed(2) and Intl.NumberFormat disagree on 1.005
published
TL;DR
(1.005).toFixed(2) returns '1.00'. new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1.005) returns '1.01'. Same input, same engine, one cent apart. toFixed rounds the exact binary double (1.00499999999999989342); Intl.NumberFormat behaves as if it rounds the decimal you typed. For money, format with Intl.NumberFormat — or keep integer cents and never round a float at all.
The problem
Node 26.2.0, one number, two built-ins:
(1.005).toFixed(2);
// '1.00'
new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(1.005);
// '1.01'
It is not limited to that one literal:
(8.575).toFixed(2); // '8.57'
(0.615).toFixed(2); // '0.61'
(2.55).toFixed(1); // '2.5'
Every one of those looks like a rounding bug in a receipt, and every one of them is the documented behaviour.
Why it happens
1.005 is not in your program. The nearest IEEE 754 double is, and it is slightly smaller:
(1.005).toFixed(20); // '1.00499999999999989342'
(8.575).toFixed(20); // '8.57499999999999928946'
Number.prototype.toFixed works from that value. It picks the integer n that makes n / 10^f closest to the actual number, so for 1.005 it compares 1.00 and 1.01 against 1.00499999999999989342 and correctly picks 1.00. There is no tie to break — the value is simply below the midpoint. MDN spells the same case out for 2.55, which returns '2.5' “as it can’t be represented exactly by a float and the closest representable float is lower”.
Intl.NumberFormat produces '1.01' on the same double, and it gives the identical answer when you hand it the string '1.005':
const nf = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
nf.format(1.005); // '1.01'
nf.format('1.005'); // '1.01'
That is the practical distinction to carry around: toFixed rounds the number that exists in memory, Intl.NumberFormat rounds the number a human would say out loud. Neither is wrong. They answer different questions, and only one of them matches what a customer expects on an invoice.
The Math.round(x * 100) / 100 trick people reach for instead is the worst of the three, because the multiply introduces its own error:
Math.round(1.005 * 100) / 100; // 1 — 1.005 * 100 is 100.49999999999999
Math.round(8.575 * 100) / 100; // 8.57
What to do
Formatting for display: use Intl.NumberFormat. It is not just about the rounding — it also gets grouping separators, currency symbols and locale digits right, which toFixed does not attempt.
const mxn = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' });
mxn.format(1234.005); // '$1,234.01'
Choose the tie rule explicitly with roundingMode, added in the Intl.NumberFormat V3 additions. The default is halfExpand (ties away from zero); accountants often want halfEven (ties to even, so rounding errors cancel over many rows):
const even = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
roundingMode: 'halfEven',
});
even.format('1.005'); // '1.00'
even.format('1.015'); // '1.02'
Nine modes exist: ceil, floor, expand, trunc, halfCeil, halfFloor, halfExpand, halfTrunc, halfEven.
For arithmetic on money, do not store floats. Keep integer cents (or minor units) and divide only at the edge, when you format:
const cents = 100 + 5; // 105, exact
mxn.format(cents / 100); // '$1.05'
Integer cents are exact up to Number.MAX_SAFE_INTEGER (9007199254740991), which is about nine quadrillion cents. If you need exact decimal arithmetic beyond that, reach for BigInt or a decimal library — not for a cleverer rounding helper.
If you are stuck with toFixed, pass it a string-free path: round with Intl.NumberFormat first and parse the result, rather than nudging values with Number.EPSILON. Epsilon hacks fix the example in the bug report and silently move the error somewhere else.
Caveats
- The
Intl.NumberFormatresults above were run on V8 (Node 26.2.0). ECMA-402 leaves some formatting details implementation-defined, so verify in your target browsers if a cent matters; thetoFixedbehaviour is fully specified in ECMA-262 and does not vary. roundingModeis part of the Intl.NumberFormat V3 additions, so it is newer thanIntl.NumberFormatitself. Feature-detect with'roundingMode' in new Intl.NumberFormat().resolvedOptions()if you support old runtimes.Intl.NumberFormatreturns a string. It is a display step, not a substitute for rounding your stored values, and parsing its output back into a number will fight you on locales with,as the decimal separator.- None of this makes floats safe for accumulating money.
0.1 + 0.2is still0.30000000000000004, no matter how you print it.