Formatting Dates and Numbers with Intl

Format dates, times, relative times, numbers, currencies, units and lists correctly in an extension's UI with the Intl APIs: matching the browser UI language, caching formatters, time zones, and testing locales.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The extension’s popup is fully translated into German, but it still says “Saved 10/2/2026” and “1,234.5 MB”, which a German reader parses as 2 October in the wrong order and a number about a thousand times smaller. Translation covers words; formats cover everything else — dates, times, “3 minutes ago”, thousands separators, currencies, units and lists (“A, B and C”). The JavaScript Intl APIs handle all of these for every locale the browser knows, with no data files to ship, as long as the extension asks for the right locale and reuses formatters instead of rebuilding them. This guide covers the formatters an extension UI typically needs. It belongs to internationalization and accessibility.

Which locale to format for

An extension’s strings come from chrome.i18n, which uses the browser’s UI language. Formats should follow the same locale, so the page reads consistently: chrome.i18n.getUILanguage() returns it (for example de or pt-BR). navigator.language usually agrees but can differ — it reflects the accept-language preference in some setups — and in a content script it describes the browser, not the page you are on. Pages you inject into have their own language (document.documentElement.lang), which matters only if your UI is mimicking the page. For time zones, Intl uses the system zone by default; dates stored as UTC timestamps are formatted into the user’s local time automatically.

Intl formatters an extension UI usesA stack of Intl APIs: DateTimeFormat for dates and times, RelativeTimeFormat for ago and in phrases, NumberFormat for numbers, currencies, percents and units, PluralRules for count-dependent text, ListFormat for lists, all keyed to the browser UI language.chrome.i18n.getUILanguage()one locale for strings and formatsDateTimeFormatdates, times, rangesRelativeTimeFormat"vor 3 Minuten"NumberFormatnumbers, currency, units, compactPluralRules + ListFormatcounts and lists
One locale string drives every formatter.

Step-by-step: locale-correct formats

1. Create formatters once, from the UI language

 1// format.js
 2export const LOCALE = chrome.i18n.getUILanguage();
 3
 4export const fmt = {
 5  date:     new Intl.DateTimeFormat(LOCALE, { dateStyle: "medium" }),
 6  dateTime: new Intl.DateTimeFormat(LOCALE, { dateStyle: "medium", timeStyle: "short" }),
 7  time:     new Intl.DateTimeFormat(LOCALE, { timeStyle: "short" }),
 8  number:   new Intl.NumberFormat(LOCALE),
 9  percent:  new Intl.NumberFormat(LOCALE, { style: "percent", maximumFractionDigits: 0 }),
10  relative: new Intl.RelativeTimeFormat(LOCALE, { numeric: "auto" }),
11  list:     new Intl.ListFormat(LOCALE, { style: "long", type: "conjunction" }),
12};

Execution context: a shared module imported by popup, options, side panel and content scripts. Constructing Intl formatters is relatively expensive; calling .format() on a cached instance is cheap. Long lists in a popup — hundreds of saved items with dates — render noticeably faster with cached formatters than with toLocaleDateString() called per row, which builds a formatter each time.

2. Format dates and times

1fmt.date.format(new Date(item.savedAt));        // en: "Oct 2, 2026"   de: "02.10.2026"   ja: "2026/10/02"
2fmt.dateTime.format(Date.now());                // de: "02.10.2026, 14:05"
3fmt.date.formatRange(start, end);               // en: "Oct 2 – 9, 2026"

Execution context: any UI context. dateStyle and timeStyle pick the locale’s conventional formats without hard-coding patterns. Store timestamps as numbers (milliseconds since epoch, UTC) in chrome.storage and format at display time; never store formatted strings, which freeze the locale and time zone of whoever saved them.

The same values in four localesA date, a large number, a currency amount and a list formatted with Intl for en-US, de-DE, fr-FR and ja-JP.Valueen-USde-DEfr-FRja-JPDate (medium)Oct 2, 202602.10.20262 oct. 20262026/10/021234567.51,234,567.51.234.567,51 234 567,51,234,567.59.99 EUR€9.999,99 €9,99 €€9.99List of 3A, B, and CA, B und CA, B et CA、B、C
Hard-coded formats are wrong for most of your users.

3. Show relative times for recent events

1const UNITS = [["year", 31536e6], ["month", 2592e6], ["week", 6048e5], ["day", 864e5], ["hour", 36e5], ["minute", 6e4], ["second", 1e3]];
2
3export function ago(ts, now = Date.now()) {
4  const diff = ts - now;
5  for (const [unit, ms] of UNITS) {
6    if (Math.abs(diff) >= ms || unit === "second") return fmt.relative.format(Math.round(diff / ms), unit);
7  }
8}
9// ago(Date.now() - 180000) → en "3 minutes ago", de "vor 3 Minuten"; with numeric:"auto", -1 day → "yesterday"

Execution context: UI code. RelativeTimeFormat handles plurals and word order for every locale. Switch to an absolute date after a week or so — “5 months ago” is less useful than a date. For a live “next run in 4 minutes” label, see showing the next scheduled run in the UI.

4. Format numbers, sizes and compact counts

1const bytes = new Intl.NumberFormat(LOCALE, { style: "unit", unit: "megabyte", maximumFractionDigits: 1 });
2bytes.format(1234.5);                                     // en "1,234.5 MB"   de "1.234,5 MB"
3
4const compact = new Intl.NumberFormat(LOCALE, { notation: "compact" });
5compact.format(15300);                                    // en "15K"   de "15.300"   ja "1.5万"
6
7fmt.percent.format(0.42);                                 // en "42%"   de "42 %"   fr "42 %"

Execution context: UI code. style: "unit" supports a fixed list of units (byte, kilobyte, megabyte, gigabyte, second, minute and more); pick the unit yourself based on magnitude. Compact notation suits badges and dense lists. Note the non-breaking spaces some locales insert before % and units — don’t strip them.

Storing values versus showing themRaw values are stored as numbers and UTC timestamps, read by any context, and formatted at render time with cached Intl formatters for the current UI language, so a language change re-renders correctly.Raw valuesms timestamps, numberschrome.storagelocale-neutralAny context readspopup, panel, CSrenderCached formattersUI languageformat()per valueText in the UIcorrect locale
Store raw values; format at the edge.

5. Format currency with the currency’s own code

1export function price(amount, currency) {
2  return new Intl.NumberFormat(LOCALE, { style: "currency", currency }).format(amount);
3}
4price(9.99, "EUR");   // en "€9.99"   de "9,99 €"
5price(1200, "JPY");   // ja "¥1,200" — no decimals for yen

Execution context: UI code. The currency (what the money is) and the locale (how the reader formats numbers) are separate inputs — a German user viewing a US price sees “9,99 $”. Never infer currency from the locale. Use integer minor units for storage and divide only at display time to avoid floating-point surprises.

6. Join lists naturally

1fmt.list.format(["Reddit", "YouTube", "X"]);    // en "Reddit, YouTube, and X"   de "Reddit, YouTube und X"

Execution context: UI code. ListFormat handles conjunctions, the Oxford comma and the Japanese 、 separator, which hand-rolled join(", ") cannot. Use type: "disjunction" for “A, B, or C”.

7. Format in the service worker and content scripts too

Intl is available in service workers and content scripts, so notification text, badge tooltips and in-page overlays can use the same module. In content scripts, remember the formatters reflect the browser’s language, which is right for your UI but may differ from the page’s language. See plurals and placeholders in messages.json for count-dependent sentences.

8. Test with several locales

1google-chrome --lang=de --user-data-dir=/tmp/de-profile --load-extension=dist/chrome

Execution context: a test machine. Launching with --lang changes the browser UI language, which drives both getMessage and getUILanguage. In unit tests, inject the locale into your format module and assert outputs for en, de, ar and ja — they cover most separator, direction and script differences.

Common mistakes

  • Hard-coded date patterns. MM/DD/YYYY is wrong for most of the world.
  • Storing formatted strings. They freeze locale and time zone.
  • Creating a formatter per row. Cache them.
  • Inferring currency from locale. They are independent.
  • Mixing navigator.language and getUILanguage(). Strings and formats disagree.

Cross-browser variation

  • Chrome / Edge: full Intl support including ListFormat, RelativeTimeFormat, formatRange and unit styles.
  • Firefox: full support; browser.i18n.getUILanguage() returns the Firefox locale.
  • Safari: supports the same APIs in current versions; check formatRange and newer NumberFormat options against your minimum Safari version.

Verification

  1. Launch with --lang=de and --lang=ja and confirm dates, numbers and lists match local conventions.
  2. Change the system time zone and confirm stored timestamps display in local time.
  3. Profile a popup listing 500 items and confirm formatter construction does not appear in the hot path.
  4. Confirm a price in USD displays with the right symbol in a German UI.

FAQ

Should I use toLocaleString?

It is fine for one-off values. For lists, cache an Intl formatter instead.

How do I show the time zone?

Add timeZoneName: "short" to DateTimeFormat options, or format in a specific zone with timeZone: "Europe/Berlin".

Can I let users pick a date format?

Yes — store their preference (for example, “ISO” or “locale”) and choose a formatter accordingly. Most users are best served by the locale default.

Does Intl add to the bundle size?

No. The locale data ships with the browser.

What about calendars other than Gregorian?

Intl.DateTimeFormat follows the locale’s default calendar and accepts a calendar option such as "islamic", "hebrew" or "japanese". Respect the locale default unless your feature is tied to a specific calendar, and keep storage in UTC milliseconds either way.

Other UI/UX Patterns & Interactive Components Resources