Intl.NumberFormat and Intl.RelativeTimeFormat: The Traps

Intl.NumberFormat and Intl.RelativeTimeFormat: The Traps

Intl.NumberFormat and Intl.RelativeTimeFormat replace your formatting library. Defaults, traps, and the 58x performance mistake to avoid.

Martin Ferret

Martin Ferret

August 25, 2026

For a long time, displaying 1234.56 as a price or -3 as "3 days ago" meant pulling in a library. The Intl object covers both needs natively, with no dependency and no locale data to ship.

Intl.NumberFormat builds a formatter for a locale, then formats as many numbers as you want with it.

      const euro = new Intl.NumberFormat('fr-FR', {
  style: 'currency',
  currency: 'EUR',
});

console.log(euro.format(1234.56)); // → 1 234,56 €
console.log(euro.format(0.5));     // → 0,50 €

    

Read it as: "describe the shape you want once, then reuse it."

Look closely at that output: the separator between 1 and 234 is not a regular space but a narrow no-break space (U+202F), and the one before the is a no-break space (U+00A0). This bites in tests. A strict comparison against '1 234,56 €' typed with ordinary spaces will fail, and the two strings look identical in the diff. Compare against the formatter's own output, or normalise the whitespace before asserting.

Why the Default Options Are Not Enough

Called with a locale and nothing else, Intl.NumberFormat applies a default that surprises people: at most three fraction digits, and no minimum.

      new Intl.NumberFormat('en-US').format(1234.5678); // → 1,234.568   rounded to 3
new Intl.NumberFormat('en-US').format(1234.5);    // → 1,234.5     not padded to 3

    

The style option changes those defaults rather than adding to them, and each style has its own idea of what is reasonable.

StyleDefault fraction digitsformat(0.256)
decimal (the default)min 0, max 30.256
percentmin 0, max 026%
currencythe currency's minor unit$0.26
unitmin 0, max 30.256 km

Two traps hide in that table. The percent style multiplies by 100, so you pass a ratio, not a percentage:

      const percent = new Intl.NumberFormat('en-US', { style: 'percent' });
percent.format(0.25); // → 25%    not 0%
percent.format(25);   // → 2,500%

    

And "the currency's minor unit" means the digit count follows the currency, not the locale:

      const yen = new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' });
yen.format(1234.56); // → ¥1,235   the yen has no minor unit, so zero decimals

    

If you need a fixed number of decimals whatever the currency, say so with minimumFractionDigits and maximumFractionDigits. And whenever you are unsure what a formatter actually decided, ask it:

      new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
  .resolvedOptions();
// → { locale: 'en-US', style: 'currency', currency: 'USD',
//     minimumFractionDigits: 2, maximumFractionDigits: 2, ... }

    

Constructing the Formatter Is the Expensive Part

Number.prototype.toLocaleString() is the same machinery behind a shorter name, and that is where the cost hides: conceptually, each call builds a brand new formatter.

      // ❌ one formatter constructed per row
rows.map(row => row.total.toLocaleString('fr-FR', {
  style: 'currency',
  currency: 'EUR',
}));

// ✅ one formatter, reused
const euro = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
rows.map(row => euro.format(row.total));

    

Constructing a formatter resolves the locale and loads its data; format() afterwards is cheap. The gap is not subtle. Formatting 200,000 numbers on Node 22:

ApproachTime
toLocaleString('fr-FR'), no options object~165 ms
Cached Intl.NumberFormat('fr-FR')~165 ms
toLocaleString('fr-FR', { style: 'currency', ... })~9,500 ms
Cached formatter with the same options~165 ms

The first two rows being identical is the interesting part: V8 keeps a cache for the plain, option-less call, so n.toLocaleString('fr-FR') in a loop costs nothing extra. Pass an options object and the cache no longer applies, and you pay roughly 58 times over. That is an engine implementation detail, not a guarantee from the spec, which is exactly why the habit worth building is to hoist the formatter out of the loop rather than to memorise which calls happen to be cached.

Compact Notation, for Dashboards

notation: 'compact' gives the "1.2M" rendering that dashboards always end up needing, correctly localised.

      const compact = new Intl.NumberFormat('en-US', { notation: 'compact' });
compact.format(1234);      // → 1.2K
compact.format(1234567);   // → 1.2M

const compactFr = new Intl.NumberFormat('fr-FR', { notation: 'compact' });
compactFr.format(1234);    // → 1,2 k
compactFr.format(1234567); // → 1,2 M

    

The rounding rule is specific to compact notation: round to the nearest integer, but always keep two significant digits. So 1000 gives 1K, 1234 gives 1.2K, and 123456 gives 123K rather than 123.5K.

      compact.format(1000);   // → 1K
compact.format(12499);  // → 12K
compact.format(12500);  // → 13K

    

compactDisplay: 'long' spells the magnitude out (1.2 million), and signDisplay: 'exceptZero' prefixes a + on positive values, which is what you want for a variation indicator.

Intl.RelativeTimeFormat Does Not Compute the Difference

This is the point that trips everyone up on first use. Intl.RelativeTimeFormat formats a value and a unit that you provide. It does no date arithmetic at all.

      const rtf = new Intl.RelativeTimeFormat('en');

rtf.format(-3, 'day'); // → 3 days ago
rtf.format(2, 'hour'); // → in 2 hours

    

Negative means the past, positive means the future. The available units are year, quarter, month, week, day, hour, minute and second, in singular or plural form.

It also formats the number exactly as given, decimals included, which is rarely what you want:

      rtf.format(-1.5, 'day'); // → 1.5 days ago

    

So the work on your side is picking a unit and rounding. A cascade over the thresholds does the job:

      const DIVISIONS = [
  { amount: 60, unit: 'second' },
  { amount: 60, unit: 'minute' },
  { amount: 24, unit: 'hour' },
  { amount: 7, unit: 'day' },
  { amount: 4.34524, unit: 'week' },
  { amount: 12, unit: 'month' },
  { amount: Number.POSITIVE_INFINITY, unit: 'year' },
];

const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });

function relativeTime(date, from = new Date()) {
  let duration = (date - from) / 1000; // in seconds, signed

  for (const division of DIVISIONS) {
    const rounded = Math.round(duration);
    if (Math.abs(rounded) < division.amount) {
      return rtf.format(rounded, division.unit);
    }
    duration /= division.amount;
  }
}

    

Rounding before comparing to the threshold is not a detail. Round afterwards and a gap of 59.6 seconds picks the second unit, rounds to 60, and prints "60 seconds ago"; 23.9 hours prints "24 hours ago". Rounding first pushes both to the next unit, which gives "1 minute ago" and "yesterday".

numeric: 'auto', the Option That Makes It Feel Human

By default the option is numeric: 'always', which produces the literal count every time. Switching to 'auto' lets the locale use its idiomatic wording when it has one.

      const always = new Intl.RelativeTimeFormat('en', { numeric: 'always' });
const auto   = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });

always.format(-1, 'day'); // → 1 day ago
auto.format(-1, 'day');   // → yesterday

always.format(0, 'day');  // → in 0 days
auto.format(0, 'day');    // → today

auto.format(-2, 'day');   // → 2 days ago   no idiom exists, so it falls back

    

The fallback is the important part: 'auto' is not a special case you have to guard. When the locale has no word for that value, you get the numeric form back, and you never write the if (days === 1) return 'yesterday' branch yourself. Other languages get their own idioms for free, including ones English does not have:

      new Intl.RelativeTimeFormat('fr', { numeric: 'auto' }).format(-1, 'day');
// → hier

new Intl.RelativeTimeFormat('es', { numeric: 'auto' }).format(2, 'day');
// → pasado mañana   Spanish has a single word for "the day after tomorrow"

    

💡 Both APIs expose formatToParts(), which returns the output broken into labelled pieces instead of a single string. That is how you style the currency symbol differently from the amount, without a regular expression.

      new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
  .formatToParts(1234.5);
// → [
//     { type: 'currency', value: '$' },
//     { type: 'integer',  value: '1' },
//     { type: 'group',    value: ',' },
//     { type: 'integer',  value: '234' },
//     { type: 'decimal',  value: '.' },
//     { type: 'fraction', value: '50' },
//   ]

    

A Word on Support

Intl.NumberFormat itself is ancient in web terms and safe everywhere. Intl.RelativeTimeFormat has been available across browsers since September 2020, when Safari 14 shipped it. The newer Intl.NumberFormat options used above, notation, compactDisplay, signDisplay, unit, came from the ECMA-402 2020 "unified" revision and landed in Safari well after Chrome and Firefox, so check the compatibility table if your support floor includes old iOS versions. Everything here works in Node 14 and above with full ICU, which is the default since Node 13.

Be aware, too, that the exact output strings depend on the ICU version bundled with the runtime, not on your code. The French grouping separator moved from U+00A0 to U+202F in ICU 63, and ICU 72 changed the space before AM/PM in dates the same way. Snapshot tests that hardcode formatted strings will break on a runtime upgrade.

Conclusion

Intl.NumberFormat and Intl.RelativeTimeFormat remove two of the most common reasons to add a formatting dependency. Build the formatter once and reuse it, be explicit about fraction digits rather than trusting the per-style defaults, and remember that the relative formatter expects you to have already chosen the unit and rounded the value.

The wider point is that Intl encodes rules you do not want to own: which separator a locale uses, how many decimals a currency has, whether a language has a word for "yesterday". Every one of those you hardcode is a bug waiting for your first non-English user.

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.