Intl.NumberFormat currency rounding mismatch

The cart shows €10.01, the invoice says €10.00, and both were produced from the same stored value. Nobody introduced a bug — two code paths applied different rounding to a value that was never exact to begin with, and the formatter silently rounded one of them.

A rounding mismatch between display and record A cart total held as a floating-point value is rounded by the formatter for display but stored unrounded. The invoice, produced by a different path with different rounding, disagrees with what the reader was shown by one cent. Where the half-cent disappears Cart total Formatter Display Invoice 10.005 (float) rounds to $10.01 stored as 10.005 invoice says $10.00
Neither side is wrong on its own; they are answering different questions about the same number.

Root cause: the formatter rounds, and rounding is not formatting

Intl.NumberFormat renders a number using the fraction-digit rules for the currency you name. When the value carries more precision than those rules allow, the formatter has to round, and it does so quietly using half-expand — the mode where a half rounds away from zero.

That is a reasonable default and a poor place to make a financial decision. The formatter’s job is presentation; how much a customer is charged is a business rule that belongs in your code, applied once, deliberately.

The problem compounds because the number arriving at the formatter usually has more precision than intended. A tax calculation, a percentage discount or a currency conversion each produce values with long fractional tails, and floating-point arithmetic adds its own — 0.1 + 0.2 is famously not 0.3, and a cart built from such sums holds a value that is close to right and not exactly right.

Two paths that round independently then disagree. The display path rounds at format time; the invoice path rounds when the record is written, possibly with a different mode; a payment provider rounds again on its side.

Default fraction digits by currency ISO 4217 assigns a minor-unit count per currency: US dollars and euros use two fraction digits, Japanese yen and Chilean pesos use none, and Kuwaiti dinars use three. The same numeric value therefore renders with different precision depending on the currency, independently of the locale. Fraction digits are a property of the currency Default digits Formatted 1234.5 USD 2 $1,234.50 EUR 2 1.234,50 € JPY 0 ¥1,235 KWD 3 KWD 1,234.500 CLP 0 $1.235
The locale decides where the symbol goes; the currency decides how many digits survive.

The currency decides the digits, not the locale

A related surprise is that the number of fraction digits comes from the currency rather than the locale. ISO 4217 assigns each currency a minor-unit count: two for dollars and euros, none for yen and Chilean pesos, three for Kuwaiti and Bahraini dinars.

That means the same value renders with different precision depending on which currency code is passed, and a hardcoded toFixed(2) is wrong for a third of the world’s currencies — it invents a fractional yen and truncates a dinar. Passing the ISO code and letting Intl apply the currency’s rules is the only approach that generalises, and it is the same division of responsibility described in date and number formatting standards: the code chooses the money, the locale chooses the writing.

Minimal reproducible example

const raw = 0.1 + 0.2 + 9.705;          // 10.005000000000001
const eur = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });

eur.format(raw);                         // "10,01 €"  — formatter rounded up
Math.round(raw * 100) / 100;             // 10.01      — agrees, by luck
(Math.floor(raw * 100) / 100).toFixed(2) // "10.00"    — the invoice path

Three lines, three answers. The value was never exactly representable, and each consumer resolved it differently.

The fix: round once, then format

// 1. Money lives in minor units as an integer — no float can drift it.
type Money = { amount: number; currency: string };   // amount in cents/fils/etc.

const digitsFor = (currency: string) =>
  new Intl.NumberFormat('en', { style: 'currency', currency })
    .resolvedOptions().maximumFractionDigits;         // ask ICU, do not hardcode

// 2. One deliberate rounding, at the boundary where a decision is made.
export function toMinorUnits(value: number, currency: string): Money {
  const factor = 10 ** digitsFor(currency);
  return { amount: Math.round(value * factor), currency };   // half away from zero
}

// 3. Formatting receives an exact value and only writes it.
export function formatMoney({ amount, currency }: Money, locale: string): string {
  const factor = 10 ** digitsFor(currency);
  return new Intl.NumberFormat(locale, { style: 'currency', currency })
    .format(amount / factor);
}

Asking resolvedOptions() for the digit count rather than hardcoding two is what makes the same code correct for yen and dinars. The rounding mode is now visible in your source, where a reviewer can see it and a finance colleague can question it.

Five rules for rendering monetary values Money is stored in minor units as integers rather than as floating-point major units. Rounding happens once, explicitly, with a chosen rounding mode, before display. The formatter is given an already-exact value so it formats rather than rounds. The ISO currency code is always passed so the correct number of fraction digits applies. And the formatted output is asserted per currency and locale in tests. Making money render predictably 1 Store money in minor units 1005, not 10.05 2 Round once, deliberately, before display with a stated rounding mode 3 Let Intl format, not round pass an already-exact value 4 Pass the currency, never a symbol the code decides the digits 5 Assert the formatted string in tests per currency, per locale
Rule one removes the class of bug; rules two and three decide who is responsible for the last digit.

Rounding modes, and choosing one on purpose

Modern engines expose roundingMode on Intl.NumberFormat, which is useful for display but does not remove the need to decide.

Half-expand rounds a half away from zero and is the intuitive default most people expect. Half-even — banker’s rounding — rounds a half toward the even neighbour, which removes the systematic upward bias when many values are summed and is what several accounting standards require. Floor and ceiling are occasionally mandated for tax or fee calculations in specific jurisdictions.

The decision is a product and compliance question rather than an engineering one, and the engineering obligation is to make it explicit and apply it in exactly one place. A codebase where three modules round independently will produce discrepancies no matter which modes they use, because the disagreement comes from rounding repeatedly rather than from any particular mode.

One further rule follows: never round an already-rounded value. Applying a discount to a rounded total and rounding again compounds the error, and the compounding is invisible until a reconciliation report finds a few cents per thousand transactions.

Displaying a price that is not yet exact

Some prices genuinely cannot be rounded before display, and it is worth separating that case rather than forcing it into the same rule.

A per-unit price of $0.0125, an exchange-rate quote, or a live estimate that has not been committed to are all values where the extra precision is the point. Rounding them to the currency’s minor unit destroys information the reader needs, and the honest presentation shows more digits than the currency normally uses.

The distinction that keeps this from undermining everything above is between an amount and a rate. An amount is money that will be charged, and it must be exact in minor units before it is shown, because the number on the screen is a promise. A rate is a factor used to compute an amount later, and it is a measurement rather than a promise — so it renders with whatever precision is meaningful, using minimumFractionDigits and maximumFractionDigits explicitly rather than the currency defaults.

Mixing the two in one type is what produces the subtle version of this bug: a value that is sometimes a rate and sometimes an amount will be rounded in some paths and not others, and the resulting inconsistency looks random. Keeping them as distinct types — even distinct field names — makes the rounding rule mechanical rather than a judgement made per call site.

The same reasoning applies to totals shown before tax or shipping is known. An estimate is a rate-like value; the number on the confirmation screen is an amount. The moment a value crosses that boundary is exactly the moment it should be rounded, once, and stored in minor units.

Verification

test.each([
  ['en-US', 'USD', 1234.5,  '$1,234.50'],
  ['de-DE', 'EUR', 1234.5,  '1.234,50 €'],
  ['ja-JP', 'JPY', 1234.5,  '¥1,235'],
  ['ar-KW', 'KWD', 1234.5,  'د.ك.\u200f 1,234.500'],
])('%s %s formats %s', (locale, currency, value, expected) => {
  expect(formatMoney(toMinorUnits(value, currency), locale)).toBe(expected);
});

Pinning the exact string per locale and currency is deliberate. It catches an accidental change of rounding mode, a hardcoded digit count and a currency-symbol regression in one assertion, and it fails loudly when a runtime’s ICU data changes.

When to escalate

If display and payment disagree after this, the difference has moved to the provider. Payment processors apply their own rounding on the amounts they receive, so the value you send must already be exact in minor units — sending a major-unit float invites a second rounding you do not control.

If totals drift only for one currency, check that the currency’s digit count is what you assumed. Currencies with zero or three minor units break every assumption built around two, and they are the ones that reach production untested.

If sums of many small values drift consistently upward, the rounding mode is the cause and half-even is likely the answer. That is a finance decision, not a code-style preference.

FAQ

Should I use a decimal library instead of integers?

Either works. Integer minor units are simpler and sufficient for currencies with a fixed minor unit, which is nearly all of them. A decimal library is worth it when you need exact intermediate arithmetic — compound interest, multi-step tax — and it does not remove the need to round once at the boundary.

Does maximumFractionDigits override the currency’s default?

Yes, and that is usually a mistake. Overriding it to two for every currency reintroduces the fractional yen. Override it only when you genuinely need sub-unit precision, such as displaying a per-unit price of $0.0125.

Why does the yen sometimes render with a full-width symbol?

Because CLDR data for the locale chooses the symbol form. currencyDisplay: 'narrowSymbol' requests the narrow variant, and the wide form is correct for some Japanese contexts — which is exactly the kind of decision worth leaving to the locale data rather than overriding globally.

Where should the rounding happen in a client-server split?

On the server, at the point the amount becomes authoritative, with the rounded minor-unit value sent to the client. A client that rounds independently will eventually disagree with the record, and the client is the side you cannot audit.

Part of Date & Number Formatting Standards.