cf.completefrontend
My progress
FUNDAMENTALS · MODULE 13

Dates, numbers, and plurals

Turning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.

Why string concatenation breaks

Picture an online shop that ships to 30 countries. The order page was written in the US, and the summary line is built by gluing pieces together: the date as month, slash, day, slash, year, then a dollar sign and the total, then the item count with an s on the end. For a customer in Ohio it reads Shipped 3/4/2026 · $1234.5 · 3 items. It looks fine, so it ships.

Now a customer in Berlin opens the same page. To her, 3/4/2026 means the 3rd of April, a month late, so she files a support ticket. The total should read 1.234,50 €: Germany uses a dot to group thousands, a comma for decimals, and puts the euro sign after the number.

A customer in Pune expects ₹1,23,456.50, because Indian English groups digits in twos after the first thousand. A customer in Cairo may expect Arabic-Indic digits. And when someone buys a single item, the page says 1 items.

Before and after for an order placed on 2026-03-04 for 1,234.50 euros. Glued by hand, all four readers see 3/4/2026 · $1234.5, which is a wrong currency, missing digits, and a date that Germans, Indians, and Japanese read differently. Formatted with Intl: en-US sees Mar 4, 2026 · €1,234.50; de-DE sees 04.03.2026 · 1.234,50 €; en-IN sees 4 Mar 2026 · €1,234.50; ja-JP sees 2026/03/04 · €1,234.50.glued by handIntl formattersen-US3/4/2026 · $1234.5Mar 4, 2026 · €1,234.50de-DE3/4/2026 · $1234.504.03.2026 · 1.234,50 €en-IN3/4/2026 · $1234.54 Mar 2026 · €1,234.50ja-JP3/4/2026 · $1234.52026/03/04 · €1,234.50one country's habits for everyoneeach reader's conventions
The left column is right for nobody: even in the US the currency and the missing zero are wrong. On the right, each reader gets their own date order and separators from Intl.DateTimeFormat and Intl.NumberFormat, while the currency stays the euro the price was set in.

Five things that go wrong

  • Order of parts. Month before day is a US habit. Most of the world writes day first; Japan, China, and Korea write year first (2026/3/4).
  • Separators. The decimal mark is a dot in the US, a comma in most of Europe, and a different glyph again in Arabic. The thousands separator can be a comma, a dot, a space, an apostrophe (Switzerland), or nothing at all.
  • Currency placement. The symbol goes before or after the number, with or without a space, and $ alone is ambiguous: US, Canadian, and Australian dollars and the Mexican peso all use it.
  • Plurals. English has two forms (1 item, 2 items). Polish and Russian have more, Arabic has six, and Japanese has one. An s on the end is wrong in almost every language.
  • Time zones. A timestamp of 15:30 UTC on March 4 is 9:00 PM in India and 7:30 AM in California. If the server picks the zone, everyone except the server's neighbours sees the wrong time.

These rules number in the thousands across hundreds of locales, and they change as countries move clocks and revise conventions. The data already exists. Formats and plural rules live in CLDR, the Unicode Common Locale Data Repository, and clock rules live in the IANA time zone database. Every modern browser ships both through the Intl APIs.

What a locale is

A locale is a language plus regional conventions, written as a BCP 47 language tag (the IETF standard format): en-US, en-GB, de-DE, hi-IN, ar-EG. The language part changes words and grammar. The region part changes formats: en-US and en-GB share a language, yet one writes 3/4/2026 and the other 04/03/2026 for the same day. Tags can also carry extensions, such as -u-nu-latn to ask for Latin digits or -u-ca-japanese for the Japanese calendar.

This lesson covers formatting values; translating sentences and the ICU message syntax are in Translating an app. The tool for every job here is an Intl formatter, configured once with a locale and options, then reused.

ts
// Before: hand-built, correct only for one country
const line = `Shipped ${m}/${d}/${y} · $${total} · ${n} items`;

// After: every value goes through a formatter for the user's locale
const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone }).format(shippedAt);
const money = new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(1234.5);
// The sentence itself, with its plural, comes from the translation catalog:
// t("order.summary", { date, money, count: n })
Format every value with Intl, then let the translated message decide word order and plurals.

The mental model: raw values in, formatting at the edge

Keep one picture in your head. Data travels through your system as raw, locale-free values: an instant in time in UTC, a plain number, an amount in minor units with an ISO currency code, a count. Nothing in the database, the API, or the client store is a formatted string. Only at the very last step, when a component renders, does a formatter turn each value into text for one user.

Think of a thermostat. The sensor stores one true temperature. One display shows Celsius and another shows Fahrenheit, but nobody stores both, and nobody does maths on the displayed text. Locale formatting works the same way: one value, many presentations, chosen at the screen.

A layered stack. In the server layer, the database row holds placed_at 2026-03-04T15:30:00Z and a total of 123450 with currency EUR, and the API response carries the same raw values: an ISO UTC instant and minor units with an ISO code. In the browser layer, cached Intl formatters configured with locale de-DE and time zone Europe/Berlin turn them into the text 04.03.2026, 16:30 · 1.234,50 €.Server: raw values onlyDatabase rowplaced_at 2026-03-04T15:30:00Z, total 123450 EURAPI response (JSON)ISO instant in UTC, amount in minor units + codeBrowser: the edgeIntl formatters (cached)locale de-DE, timeZone Europe/Berlin04.03.2026, 16:30 · 1.234,50 €
Nothing above the browser is a formatted string. The same API response becomes Mar 4, 2026, 10:30 AM · €1,234.50 for a reader in New York: only the bottom two boxes change.

Every formatter takes three inputs

  1. The value: a Date (or epoch milliseconds), a number or bigint, a list of strings, a count.
  2. The locale: who is reading, as a BCP 47 tag or a list of tags in order of preference, such as ["de-AT", "de", "en"]. The runtime picks the best one it supports.
  3. The options: what kind of output you want. For dates that includes the time zone, which is not part of the locale. A German user living in Tokyo wants German formats in Tokyo time.

The locale and the time zone are separate settings, and this trips people up. The locale en-IN says nothing about where the user is right now, and an IANA time zone like America/New_York says nothing about which language they read. Ask the browser for each one separately: navigator.languages for the locale list, Intl.DateTimeFormat().resolvedOptions().timeZone for the zone.

What the API should send

Values cross the network raw; the client formats them.
Kind of valueSend thisNot this
A moment (order placed, message sent)"2026-03-04T15:30:00Z" (ISO 8601 in UTC) or epoch ms 1772638200000"Mar 4, 3:30 PM" or a local time without an offset
A calendar date (birthday, due date)"2026-03-04" as a plain date, no time, no zoneMidnight UTC, which becomes March 3 in New York
Money{ amount: 123450, currency: "EUR" } in minor units"€1,234.50" or a float like 1234.5 with no currency
A count3"3 items"
A measurement{ value: 12.5, unit: "megabyte" }"12.5 MB"

One exception: with server-side rendering (SSR), the server is the edge for the first paint, so it formats too, with the same locale and zone the browser will use. The failure modes chapter covers why.

Choosing the locale

Pick the locale in this order: an explicit user setting saved on their account, then the locale in the URL (such as /de/ paths), then the browser's navigator.languages (or the Accept-Language header on the server), then your default. Pass the whole preference list to Intl, not just the first entry, so the runtime can fall back from de-AT to de on its own.

After you create a formatter, resolvedOptions().locale tells you what the runtime actually chose. That is useful for logging and for tests: ask for ["xx-YY", "fr"] and Node prints fr, because it has no data for the made-up first tag and moves down the list. Passing undefined as the locale means "use the runtime default", which is the user's browser locale on the client but whatever the machine was set to on a server.

Check your understanding · Exercise 1

A ride-sharing app shows each trip as Mar 4, 9:00 PM · ₹245.00. Which of these should the trips API return? Select all that apply.

Dates and times with Intl.DateTimeFormat

Intl.DateTimeFormat turns an instant into text. You create it once with a locale and options, then call .format(date) as often as you like. Every example in this lesson was run in Node 22.23 with ICU 78 (International Components for Unicode, the C library that holds the CLDR data). Browsers ship their own ICU, so a comma or a space can differ slightly from what you see here.

Start with dateStyle and timeStyle

The easiest options are dateStyle and timeStyle, each full, long, medium, or short. You choose how much detail; the locale decides everything else. For the instant 2026-03-04T15:30:00Z shown in UTC:

Node 22, ICU 78. Pick a style by how much room you have, not by what the parts look like.
StyledateStyle in en-UStimeStyle in en-USdateStyle in de-DE
fullWednesday, March 4, 20263:30:00 PM Coordinated Universal TimeMittwoch, 4. März 2026
longMarch 4, 20263:30:00 PM UTC4. März 2026
mediumMar 4, 20263:30:00 PM04.03.2026
short3/4/263:30 PM04.03.26

Combine them for a date and a time: { dateStyle: "medium", timeStyle: "short" } prints Mar 4, 2026, 3:30 PM in en-US, 4 Mar 2026, 15:30 in en-GB, and 2026/03/04 15:30 in ja-JP. Notice that en-GB switches to a 24-hour clock on its own. You never write a pattern like MM/dd/yyyy.

Ask for fields, not patterns

When a style is too much or too little, list the fields you want instead: weekday, year, month, day, hour, minute, second, timeZoneName, each with a width such as numeric, 2-digit, short, or long. You say which fields; the locale decides their order and punctuation. The same options { weekday: "short", month: "short", day: "numeric" } give Wed, Mar 4 in en-US and Mi., 4. März in de-DE.

  • You cannot mix the two. Passing dateStyle together with hour throws a TypeError. Pick styles or fields per formatter.
  • hourCycle forces a clock: h23 gives 15:30 even in en-US. Use it only when the user chose a 24-hour clock in your settings; otherwise let the locale decide.
  • timeZoneName adds the zone: in en-US, short gives EST or GMT+5:30, shortOffset always gives an offset such as GMT-5, and long gives Central European Standard Time. It is a field, so it cannot be combined with dateStyle or timeStyle either. Show it whenever people in different zones look at the same time, such as a meeting invite.
  • Calendars and digits come from the locale. th-TH prints the Buddhist year (4 มี.ค. 2569), fa-IR the Persian calendar (۱۳ اسفند ۱۴۰۴), and ja-JP-u-ca-japanese the era (令和8年3月4日). Test with these locales; they catch code that assumes the year is 2026 everywhere.

formatToParts: style pieces without breaking order

Designers often want one part styled differently, such as the day in bold on a calendar tile. The tempting fix is to format the day and the month separately and glue them together, which puts the old bug back. formatToParts returns the formatted string as an array of typed pieces in the locale's order, so you can wrap the piece you want and keep the rest as is.

formatToParts for the date 2026-03-04 in long style. In en-US the parts are month March, literal space, day 4, literal comma and space, year 2026. In de-DE the parts are day 4, literal dot and space, month März, literal space, year 2026. The day part is highlighted in both, at a different position.en-USMarchmonth" "literal4day", "literal2026yearde-DE4day". "literalMärzmonth" "literal2026yearstyle the day part; the order stays the locale's
Same options, different order. Wrapping the part whose type is day in strong bolds the right piece in every locale without rebuilding the string.
tsx
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone });

function DateLabel({ at }: { at: Date }) {
  return (
    <time dateTime={at.toISOString()}>
      {fmt.formatToParts(at).map((part, i) =>
        part.type === "day" ? <strong key={i}>{part.value}</strong> : part.value,
      )}
    </time>
  );
}
The time element keeps the machine-readable instant; the parts keep the locale's order.

Each part has a type (day, month, year, hour, minute, dayPeriod, timeZoneName, literal, and a few more) and a value. For en-US long style, March 4, 2026 comes back as month, literal, day, literal, year. Intl.NumberFormat has the same method, which we use later for currency.

Ranges with formatRange

For stays and sales, formatRange(start, end) removes the parts the two ends share. Two dates in the same month print as Mar 4 – 9, 2026 in en-US. Two times on the same day print as Mar 4, 2026, 3:30 – 5:00 PM. Two dates across months in en-GB print as 4 Mar – 2 Apr 2026.

Create once, format many times

Building a formatter is the expensive part: the runtime resolves the locale, loads CLDR data, and compiles a pattern. Calling .format() on an existing one is cheap.

In a quick Node 22 test on a laptop, 20,000 calls to date.toLocaleDateString("de-DE", options) took about a second, while 20,000 calls to one reused formatter took about 30 ms. Exact numbers vary by machine, but the gap is large. toLocaleDateString with options builds a new formatter each call, so in a list of 500 rows it is a real cost.

Keep formatters at module level, in a useMemo, or in a cache keyed by locale plus options; i18n libraries do this for you.

Check your understanding · Exercise 2

You need a compact label like Wed, Mar 4 on a calendar for any locale. Which approaches are correct? Select all that apply.

Time zones, DST, and what to store

Most date bugs are time zone bugs. The rule that prevents most of them is short: store and send instants in UTC, convert to the user's zone only when you display them. UTC never jumps forward or back, so sorting, comparing, and subtracting instants always works. Every local zone can jump.

Zones are names, not offsets

A time zone is a region with a history of rules, named in the IANA time zone database: America/New_York, Europe/Berlin, Asia/Kolkata, Asia/Kathmandu. An offset like -05:00 is only what a zone uses on one date. New York is -05:00 in winter and -04:00 in summer, so storing -05:00 as someone's zone is wrong half the year. Offsets are not always whole hours either: Kolkata is +05:30 and Kathmandu is +05:45, so 15:30 UTC prints as 9:15:00 PM GMT+5:45 there.

Pass the zone name to the formatter with timeZone. The browser reports the user's zone with Intl.DateTimeFormat().resolvedOptions().timeZone, and Node 22 knows 418 canonical zone names through Intl.supportedValuesOf("timeZone"). An unknown name throws a RangeError, so validate zone names from user settings before you use them.

Daylight saving time: the hour that is missing and the hour that repeats

Twice a year, many zones move their clocks. In New York on Sunday, March 8, 2026, local time jumps from 1:59 AM straight to 3:00 AM. The wall-clock time 2:30 AM does not exist that day. On November 1, 2026, the clock falls back from 1:59 AM to 1:00 AM, so 1:30 AM happens twice, an hour apart.

Two timelines of four consecutive UTC hours in America/New_York. On March 8, 2026: 05:00Z is 12:00 AM EST, 06:00Z is 1:00 AM EST, 07:00Z is 3:00 AM EDT, 08:00Z is 4:00 AM EDT, so local 2:00 to 2:59 AM never happens. On November 1, 2026: 04:00Z is 12:00 AM EDT, 05:00Z is 1:00 AM EDT, 06:00Z is 1:00 AM EST, 07:00Z is 2:00 AM EST, so 1:00 to 1:59 AM happens twice.Sun, March 8, 2026, New York: spring forward05:00Z12:00 AM EST06:00Z1:00 AM EST07:00Z3:00 AM EDT08:00Z4:00 AM EDT1:59 AM jumps to 3:00 AM: 2:30 AM never happensSun, November 1, 2026, New York: fall back04:00Z12:00 AM EDT05:00Z1:00 AM EDT06:00Z1:00 AM EST07:00Z2:00 AM EST1:00 AM to 1:59 AM happens twice, an hour apart
UTC moves one hour per box; the local clock does not. A local time like 2:30 AM on March 8 has no instant, and 1:30 AM on November 1 has two, so a wall-clock time alone is not enough to find the moment.

This breaks code in three common ways. First, new Date("2026-03-08T02:30") on a machine in New York quietly becomes 3:30 AM EDT: a wall-clock time that never existed is moved, not rejected.

Second, adding 24 * 60 * 60 * 1000 milliseconds to noon on March 7 gives 1:00 PM on March 8, because that day is only 23 hours long. Adding one calendar day with setDate(getDate() + 1) keeps 12:00 PM. Third, a daily report bucketed by local hour has a missing hour in March and a doubled hour in November.

  • Durations (a 30-minute timer, a session timeout): add milliseconds to the UTC instant.
  • Calendar steps (same time tomorrow, the 1st of next month): do the maths in the user's zone with calendar-aware code, then convert back to an instant.
  • Recurring events (a weekly 9:00 AM standup): store the wall-clock time plus the IANA zone name, and compute each occurrence. Storing one UTC instant and adding 7 days moves the meeting by an hour after the next clock change.

Not everything is an instant

Some values are not moments at all. A birthday, a hotel check-in date, or a public holiday is a plain date: March 4 is March 4 wherever you are. If you store it as midnight UTC, new Date("2026-03-04") is parsed as UTC (date-only ISO strings are), and a user in New York sees 3/3/2026. Store plain dates as "2026-03-04" strings or as a date column without a time, and never pass them through Date.

A decision tree for storing time values. First question: is it a single moment, like an order placed or a message sent? Yes: store a UTC instant such as 2026-03-04T15:30:00Z. No: does it repeat at a local clock time, like a 9:00 AM standup? Yes: store the wall time plus an IANA zone, such as 09:00 and America/New_York. No: is it a length of time, like a timer? Yes: store a duration in milliseconds, such as 1800000. No: store a plain date, such as 1990-03-04 for a birthday.A single moment?order placed, message sentyesUTC instant2026-03-04T15:30:00ZnoRepeats at a local time?9:00 AM standup, daily reminderyeswall time + zone09:00, America/New_YorknoA length of time?timer, session timeoutyesduration in ms1800000 (30 minutes)noplain datebirthday 1990-03-04, no time, no zone
Only the first answer is an instant. Squeezing the others into a Date is how birthdays move a day and weekly meetings move an hour.

Temporal: the replacement for Date

Date has one type for everything, so it cannot tell an instant from a plain date. The new Temporal API from TC39 (the committee that evolves JavaScript) gives each idea its own type: Temporal.Instant for a moment, Temporal.ZonedDateTime for a moment in a named zone, Temporal.PlainDate for a calendar date, Temporal.PlainTime for a wall-clock time, and Temporal.Duration. Its maths is DST-aware: adding { days: 1 } to a ZonedDateTime keeps the wall-clock time across a clock change.

ts
// Temporal (native where shipped, or a polyfill such as @js-temporal/polyfill)
const standup = Temporal.ZonedDateTime.from("2026-03-07T09:00[America/New_York]");
standup.add({ days: 1 }).toString();
// "2026-03-08T09:00:00-04:00[America/New_York]": still 9:00 after the clock change

const birthday = Temporal.PlainDate.from("1990-03-04"); // no zone, no time
standup.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
Each Temporal type says what kind of time it holds, so a birthday can no longer shift a day.

Check your understanding · Exercise 3

A habit app sends a reminder "every day at 8:00 AM" in the user's zone, Europe/Berlin. Which designs keep it at 8:00 AM all year? Select all that apply.

Numbers, currencies, and units with Intl.NumberFormat

Intl.NumberFormat does for numbers what DateTimeFormat does for dates. With no options it applies the locale's digits, decimal mark, and grouping. The number 1234567.891 alone shows how much varies:

A matrix of the number 1234567.891 formatted by Intl.NumberFormat in six locales, with the group separator, decimal separator, and grouping of each. en-US: 1,234,567.891, comma, dot, groups of 3. de-DE: 1.234.567,891, dot, comma, groups of 3. fr-FR: 1 234 567,891, narrow no-break space U+202F, comma. de-CH: 1'234'567.891, apostrophe, dot. en-IN: 12,34,567.891, comma, dot, groups of 2 after the first 3. ar-EG: Arabic-Indic digits with the Arabic thousands separator U+066C and decimal separator U+066B.outputgroupdecimalgroupingen-US1,234,567.891commadot3sde-DE1.234.567,891dotcomma3sfr-FR1 234 567,891U+202Fcomma3sde-CH1'234'567.891apostrophedot3sen-IN12,34,567.891commadot2s after 3ar-EG١٬٢٣٤٬٥٦٧٫٨٩١U+066CU+066B3s
Highlighted cells differ from US English. The French group separator is an invisible character, not a space, which is why tests that compare to a typed literal fail. Read the symbols with formatToParts instead of guessing.

Three details in that picture surprise people. Indian English groups as 12,34,567: thousands first, then pairs (lakh and crore). Spanish and Polish do not group four-digit numbers at all, so 1234 stays 1234 while 12345 becomes 12.345.

And the French space is not a normal space: it is U+202F, a narrow no-break space, and the German currency output uses U+00A0, a no-break space. A test that compares against "1.234,50 €" typed with a normal space fails, which is a very common Intl test bug.

Currency: always with an ISO code

Set style: "currency" and a three-letter ISO 4217 currency code. The code is required; leaving it out throws a TypeError. The currency says which money it is; the locale says how to write it. They are separate: a German user looking at a US price sees 1.234,50 $, while a US user sees $1,234.50.

Node 22, ICU 78, value 1234.5 unless noted.
OptionsLocaleOutput
currency: "EUR"en-US / de-DE / fr-FR€1,234.50 / 1.234,50 € / 1 234,50 €
currency: "INR" (value 123456.5)en-IN₹1,23,456.50
currency: "JPY"en-US / ja-JP¥1,235 / ¥1,235
currency: "KWD"en-USKWD 1,234.500
currency: "CAD"en-USCA$1,234.50
currency: "USD", currencyDisplay: "name"en-US1,234.50 US dollars
currency: "USD", currencySign: "accounting" (value -42)en-US($42.00)

Look at the decimals. The yen has no minor unit, so 1234.5 is rounded to ¥1,235. The Kuwaiti dinar has three decimals.

This is why money should travel as integer minor units plus a code: { amount: 123450, currency: "EUR" } means 1,234.50 euros. To display it, read the currency's digits from resolvedOptions().maximumFractionDigits (2 for EUR, 0 for JPY, 3 for KWD) and divide by that power of ten. Never do money arithmetic in floating point: 0.1 + 0.2 is 0.30000000000000004.

  • currencyDisplay: symbol (default), narrowSymbol ($ even for CAD, for tight spaces where the currency is obvious), code (USD 1,234.50, clearest in multi-currency tables), or name.

Compact notation, units, and percent

Compact notation writes big counts the way people say them: notation: "compact" turns 1234567 into 1.2M in en-US, 1,2 Mio. in de-DE, 12L (lakh) in en-IN, and 123万 in ja-JP, because Japanese counts in units of ten thousand. A hand-written (n / 1e6).toFixed(1) + "M" gets every one of those wrong. Add compactDisplay: "long" for 1.2 million.

Units use style: "unit" with a unit from a fixed list (Node 22 knows 45, from byte to kilometer-per-hour). 50 km/h in English becomes 50 Kilometer pro Stunde in German with unitDisplay: "long", and the unit word is pluralized for you (1 liter, 2 liters). Percent multiplies by 100: 0.256 prints 26% in en-US and 26 % in de-DE and fr-FR, with a no-break space. Pass the fraction, not the already-multiplied number.

Rounding, digits, and signs

  • minimumFractionDigits and maximumFractionDigits control decimals. { minimumFractionDigits: 2 } turns 5 into 5.00. maximumSignificantDigits: 3 turns 123456 into 123,000.
  • Rounding is decimal, not binary. (1.005).toFixed(2) gives "1.00" because of float error, while NumberFormat with two decimals gives 1.01. The default rounds half away from zero (2.5 becomes 3); roundingMode: "halfEven" gives banker's rounding (2.5 becomes 2).
  • signDisplay: "exceptZero" shows +5 and 0, which suits change indicators such as +4% on a dashboard.
  • formatRange(3, 5) prints $3.00 – $5.00 for price ranges.

Parsing is the hard direction

Intl formats but does not parse. parseFloat("1.234,50") returns 1.234, silently wrong by a factor of a thousand, and Number("1.234,50") returns NaN. For numeric inputs, prefer input type="number" with inputmode="decimal" where it fits, or use formatToParts(11111.1) to learn the locale's group and decimal symbols, strip the group symbol, and swap the decimal one for a dot before converting. Then re-display the parsed value through the formatter so the user sees what you understood.

Check your understanding · Exercise 4

A marketplace stores prices as floats in dollars and shows them with "$" + price.toFixed(2). It is expanding to Japan and Germany. What should change? Select all that apply.

Plurals, relative times, and lists

The 1 items bug from the first chapter is a plural bug, and English hides how big the problem is. English needs two forms. Other languages need one, three, four, five, or six. Intl.PluralRules tells you which form a number needs in a locale, as a plural category: one of zero, one, two, few, many, or other.

Intl.PluralRules: cardinal categories

new Intl.PluralRules(locale).select(n) in Node 22. French treats 0 and 1.5 as singular; Russian 21 is singular.
LocaleCategories it uses012521221.5
enone, otherotheroneotherotherotherotherother
frone, many, otheroneoneotherotherotherotherone
plone, few, many, othermanyonefewmanymanyfewother
ruone, few, many, othermanyonefewmanyonefewother
arall sixzeroonetwofewmanymanyother
jaotherotherotherotherotherotherotherother

The category is not the number. In Russian, 21 takes the one form (21 файл), and in Polish, 22 takes few while 25 takes many. This is why count === 1 ? "item" : "items" cannot be patched per language: the rules depend on the last digits, and sometimes on visible decimals. select(1) is one in English, but with { minimumFractionDigits: 1 } the number is shown as 1.0 and select returns other, matching 1.0 items.

For sentences, translators write every form in an ICU message ({count, plural, one {# item} other {# items}}) and the i18n library calls PluralRules for you; see Translating an app. Call it directly for things outside messages, and use resolvedOptions().pluralCategories to check that a catalog has every form a locale needs.

Locale
Rules
Loading plural rules in your browser.01234567891011121314151617181920212223242526272829loading
Counts 0 to 29 marked by new Intl.PluralRules("pl", { type: "cardinal" }).select(n), computed by your browser. Colours: one is green and outlined, two green, few warm, many hatched, zero red, other plain.

Type a number, such as 1.5 or 101, to see its category.

Ordinals: 1st, 2nd, 3rd

Rankings and "your 3rd order" use ordinal rules, a separate set: new Intl.PluralRules("en", { type: "ordinal" }). English has four ordinal categories: one (1, 21, 101), two (2, 22), few (3, 23), and other (4, 11, 12, 13, 111). The teens are the trap that hand-written code misses.

Swedish has two, Italian has many only for 11 (and 8, 80, 800), and Japanese has one. In ICU messages the same rules are used through selectordinal.

ts
const ordinal = new Intl.PluralRules("en-US", { type: "ordinal" });
const suffix: Record<string, string> = { one: "st", two: "nd", few: "rd", other: "th" };
const nth = (n: number) => `${n}${suffix[ordinal.select(n)]}`;

[1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 111].map(nth).join(" ");
// "1st 2nd 3rd 4th 11th 12th 13th 21st 22nd 23rd 111th"
The suffix map is English; other languages put ordinals in translated messages instead.

Intl.RelativeTimeFormat: yesterday, in 2 weeks

Feeds and inboxes show times like 3 hours ago. Intl.RelativeTimeFormat writes the phrase; you give it a signed number and a unit. With numeric: "auto" it uses words where a language has them: in English -1 day is yesterday, 0 day is today, and -1 month is last month. With the default numeric: "always" you get 1 day ago and the odd in 0 days.

With numeric: "auto". Hindi uses the same word for yesterday and tomorrow, which is why you never translate the word alone.
Localeformat(-1, "day")format(-3, "hour")format(2, "day")
enyesterday3 hours agoin 2 days
degesternvor 3 Stundenübermorgen
esayerhace 3 horaspasado mañana
hiकल3 घंटे पहलेपरसों
ja昨日3 時間前明後日

The formatter does not choose the unit, so you do. Compute the difference in seconds and pick the largest unit that fits: under a minute, seconds; under an hour, minutes; then hours, days, weeks, months, years. Use calendar days for yesterday (crossing local midnight), not 24-hour blocks, or a message sent at 11:50 PM shows today at 12:10 AM.

Relative times also go stale, so re-render them on a timer (every minute is plenty) and keep the absolute time in a title or a time element's dateTime for anyone who needs it.

Intl.ListFormat and Intl.DisplayNames

"Ana, Ben, and Chen liked this" is another concatenation trap. Intl.ListFormat joins items with the locale's words and commas: Ana, Ben, and Chen in en, Ana, Ben and Chen in en-GB (no serial comma), Ana, Ben und Chen in de, Ana、Ben、Chen in ja. Use type: "disjunction" for "or" lists. It even knows that Spanish y becomes e before an i sound: Ana e Isabel.

Intl.DisplayNames gives names of languages, regions, and currencies in the reader's language, which is ideal for a language picker or a country dropdown: new Intl.DisplayNames("de", { type: "language" }).of("fr") is Französisch, and the region DE in English is Germany. No translated country list to maintain.

Locale
Instant
The instant 2026-03-04T15:30:00Z, the amount 1234.5, and the count 2, formatted by this browser for de-DE in Europe/Berlin
APIOutput
LoadingFormatting in your browser…

Check your understanding · Exercise 5

A notification reads "You have " + n + (n === 1 ? " new message" : " new messages"). Which statements are true? Select all that apply.

Sorting and cutting text: Collator and Segmenter

Formatting is not only about output. Two everyday operations quietly assume English too: sorting a list of names, and cutting a long string to fit. Both have Intl answers.

Intl.Collator: sort the way readers expect

array.sort() with no comparator sorts by UTF-16 code units. Every uppercase ASCII letter comes before every lowercase one, and accented letters land after z. Sorting ["zebra", "Äpfel", "apple", "Zoo", "éclair", "Eagle"] that way gives Eagle, Zoo, apple, zebra, Äpfel, éclair, which no human would call alphabetical.

new Intl.Collator(locale).compare sorts by the language's rules, and the rules really differ. In English and German, Äpfel sorts next to apple. In Swedish, ä is its own letter after z, so Äpfel goes to the end.

In Czech, ch is one letter that comes after h. A contact list sorted with the wrong rules looks broken to a native reader.

  • numeric: true sorts digits by value: item1, item2, item10 instead of item1, item10, item2. Use it for file names, versions, and anything with numbers inside.
  • sensitivity: "base" ignores case and accents when comparing, so resume equals Résumé (compare returns 0). Combine it with usage: "search" for a forgiving filter box.
  • caseFirst: "upper" puts A before a when they would otherwise tie.
  • Reuse it. localeCompare(other, locale, options) can build a collator per call, like toLocaleDateString (V8 caches only the simplest calls). For a list of 10,000 names, create one Collator and pass its compare to sort.

Sorting large lists is usually the server's job, with paging. Make sure the database uses a matching collation for the user's locale, or the first page (sorted by the server) and the client's re-sort after an edit will disagree.

Intl.Segmenter: cut text where readers see breaks

A string's .length counts UTF-16 code units, not characters. A grapheme (what a reader sees as one character) can be many code units. The family emoji 👨‍👩‍👧‍👦 has a length of 11: four person emoji, each two code units, joined by three zero-width joiners.

The flag 🇮🇳 is two regional indicator letters, length 4. An é typed as e plus a combining accent has length 2.

The family emoji in three layers. As UTF-16 code units it is 11 boxes: D83D DC68, 200D, D83D DC69, 200D, D83D DC67, 200D, D83D DC66, so .length is 11. As code points it is 7: man, zero-width joiner, woman, joiner, girl, joiner, boy, which is what spreading the string gives. As graphemes it is 1, which is what Intl.Segmenter returns and what the reader sees.UTF-16 code units: .length is 11D83DDC68200DD83DDC69200DD83DDC67200DD83DDC66code points: [...text].length is 7U+1F468ZWJU+1F469ZWJU+1F467ZWJU+1F466graphemes: Intl.Segmenter gives 1👨‍👩‍👧‍👦what the reader sees: one character
.slice() cuts between any two top boxes, [...text] between any two middle boxes, and only Intl.Segmenter respects the single box at the bottom.

Cutting by .slice() can split a grapheme in half. "Party time 👨‍👩‍👧‍👦 tonight".slice(0, 12) ends with a lone surrogate (�), which renders as a box with a question mark. Spreading into an array ([...text]) is better, because it splits by code points, but it still breaks the family into four people and the flag into two letters.

ts
const graphemes = new Intl.Segmenter(locale, { granularity: "grapheme" });

function truncate(text: string, max: number): string {
  const parts = Array.from(graphemes.segment(text), (s) => s.segment);
  return parts.length <= max ? text : parts.slice(0, max).join("") + "…";
}

truncate("Party time 👨‍👩‍👧‍👦 tonight", 12); // "Party time 👨‍👩‍👧‍👦…"
Count and cut by graphemes for character limits, previews, and avatars built from initials.

Use grapheme counts anywhere a user sees a limit, such as 140 characters left. Be aware that the rules evolve with Unicode: in Node 22 (ICU 78), the Hindi greeting नमस्ते is 3 graphemes, while runtimes before Unicode 15.1 count 4, because the rules for joined consonants changed. Server-side limits must use the same definition, or the server rejects text the client accepted.

granularity: "word" finds word boundaries in languages without spaces. Japanese 東京都に住んでいます (I live in Tokyo) splits into 東京, 都, に, 住, んで, い, ます, and Thai สวัสดีครับ into สวัสดี and ครับ. Splitting on spaces finds one word in each. This matters for word counts, double-click selection in custom editors, and search highlighting.

Check your understanding · Exercise 6

A profile shows the first 10 characters of a display name. Users report broken boxes at the end of some names. Which fixes are right? Select all that apply.

Failure modes: what breaks in production

Locale bugs rarely show up on the developer's machine, where the defaults match the code. They appear on a server, in CI, or in a browser on the other side of the world.

Server and client disagree: hydration mismatches

With server-side rendering, the same component runs twice: once on the server to produce HTML, once in the browser to hydrate it (attach event handlers to the existing HTML). If a formatter uses the runtime's default zone or locale, the two runs see different defaults. A server in a data centre usually runs in UTC with an en-US default; the user's laptop runs in Asia/Kolkata.

A sequence diagram with three columns: a server running in UTC, the browser, and React hydrating in a browser in Asia/Kolkata. The browser requests /post/42. The server formats 15:30 UTC with its default zone and returns HTML with Posted 3:30 PM. The browser loads the bundle and React hydrates. React formats the same instant in the user's zone and gets Posted 9:00 PM. The texts differ, so React reports a hydration mismatch and replaces the text, and the user sees it change.Server, TZ=UTCBrowserReact in KolkataGET /post/42Posted 3:30 PMload bundle, hydratePosted 9:00 PMmismatch: replace textFix: the same locale and timeZone on both sides,or render a stable value first
Both sides ran the same component with the same instant. The only difference is the default timeZone, which is why every formatter that can run on the server needs an explicit zone.

The server renders 3:30 PM; the browser computes 9:00 PM. React logs a hydration error and replaces the text, so the time flickers. Near midnight even the day can differ, and relative times like 5 minutes ago can drift by the time the bundle runs.

Decision How do you render user-local times with SSR without a mismatch?

Tell the server the zone and locale

On the first visit, the client stores its zone (from resolvedOptions().timeZone) and locale in a cookie or in the user's profile.

The server reads it on each request and passes the same timeZone and locale to every formatter.

Pros

  • Correct text in the first HTML, no flicker
  • Works for emails and PDFs rendered on the server too

Cons

  • The very first visit has no cookie yet, so it needs a fallback
  • Cached HTML must vary by zone, which hurts CDN caching

Render a stable value, then localize after mount

The server renders the absolute time in UTC or the date only.

A client effect replaces it with the local or relative text once hydrated.

Pros

  • HTML is cacheable and identical for everyone
  • Simple to add to existing code

Cons

  • Text changes once after load, which can shift layout
  • Users without JavaScript see UTC

Render on the client only

Skip the time on the server (a placeholder of fixed width) and format it in the browser.

Pros

  • No mismatch possible

Cons

  • Empty slot in the first paint
  • Not available to crawlers

What we pick: For signed-in apps, store the zone and locale with the user and pass them explicitly to every formatter on both sides. For public, CDN-cached pages, render a stable absolute value on the server and switch to local or relative text after mount, reserving its width. Use React's suppressHydrationWarning only on the single time element whose text you expect to differ, never as a blanket fix.

Different runtimes, different ICU data

Each runtime bundles its own copy of ICU and CLDR, and they update on their own schedules. CLDR 42 (shipped in ICU 72, late 2022) made English times use U+202F, a narrow no-break space, before AM and PM. When that reached Chrome 110 in early 2023, snapshot tests broke, and code that parsed formatted dates with new Date(string) failed in some browsers.

V8, the engine in Chrome and Node, now swaps it back to a normal space in format() output for compatibility, so Node 22 prints 3:30 PM with a plain space. Not every path does: in the same Node, formatRange still puts U+202F before PM, and other engines make their own choices. The lesson: formatted strings are for humans, not for machines.

  • Never parse formatted output. Keep the raw value next to the text. If you need parts, use formatToParts.
  • Write tests that survive data updates. Compare against the output of the same formatter (expect(text).toBe(fmt.format(value))), or normalize spaces with .replace(/\s/g, " ") before comparing to a literal.
  • Pin the zone and locale in CI. Set TZ=UTC and pass explicit locales, or a test that passes in Bangalore fails in a US build agent.
  • Check server ICU. Node ships full ICU by default since version 13, but slim Docker images, other server runtimes, or old builds can carry less data, and unsupported locales silently fall back to English.

Date parsing and default traps

  • Date-only strings are UTC; date-time strings without an offset are local. new Date("2026-03-04") is midnight UTC (March 3 in New York), while new Date("2026-03-04T00:00") is midnight local time. Same-looking input, different instants.
  • Non-ISO strings are not portable. new Date("03/04/2026") depends on the engine. Accept only ISO 8601 from APIs.
  • Defaults are the machine's. toLocaleString() with no arguments uses the runtime's locale and zone, so the same line prints 3/4/2026, 3:30:00 PM on a UTC server and 3/4/2026, 9:00:00 PM on a laptop in India. Always pass a locale and, for times, a timeZone.

Check your understanding · Exercise 7

A Next.js page shows Posted 3:30 PM from the server and then flickers to Posted 9:00 PM, with a hydration warning in the console. Which fixes address the cause? Select all that apply.

In real products: libraries, performance, and accessibility

How teams put it together

A few patterns show up again and again in products that do this well. Stripe's API sends every amount as an integer in the smallest currency unit with a separate currency code, exactly the money shape from chapter 2.

GitHub renders timestamps with its open-source relative-time custom element: the HTML carries an ISO instant, and the browser turns it into 3 hours ago or a full date with Intl, in the page's language and the reader's own time zone. Calendar apps store events as instants plus the organizer's IANA zone, so a recurring 9:00 AM meeting stays at 9:00 AM across clock changes.

Most React apps reach Intl through an i18n library such as FormatJS (react-intl) or i18next, which cache formatters and use them inside ICU messages ({total, number, ::currency/EUR}).

Performance: zero kilobytes, if you let it be

The Intl APIs ship with the browser, so formatting costs no JavaScript bytes. Older stacks bundled locale data themselves: Moment.js with all its locales is about 300 KB of minified JavaScript (roughly 75 KB gzipped, per Bundlephobia), and its own team has called it a legacy project in maintenance mode since 2020. Date maths libraries such as date-fns remain useful for things Intl does not do (adding months, start of week), but let Intl do the formatting.

  • Cache formatters by locale and options. Building one costs tens of times more than a format call, as the Node test in chapter 3 showed.
  • Avoid polyfills you do not need. DateTimeFormat, NumberFormat, PluralRules, RelativeTimeFormat, ListFormat, Collator, and Segmenter are available in all current major browsers. Load a FormatJS polyfill only for the newest APIs or old browsers you must support, and load it conditionally.
  • Newer APIs to watch. Intl.DurationFormat (for 1 hr, 30 min and timer-style 1:05:09) is newer and not in Node 22; check support before using it.
  • Reserve space. Formatted text changes width between locales and when a relative time updates. Give timestamps a stable width or tabular-nums so lists do not jump, which helps Cumulative Layout Shift.
ts
const cache = new Map<string, Intl.NumberFormat>();

export function numberFormat(locale: string, options: Intl.NumberFormatOptions = {}) {
  const key = locale + JSON.stringify(options);
  let fmt = cache.get(key);
  if (!fmt) {
    fmt = new Intl.NumberFormat(locale, options);
    cache.set(key, fmt);
  }
  return fmt;
}

numberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5); // "1.234,50 €"
One shared cache: every component gets the same formatter for the same locale and options.

Accessibility

  • Set lang on the page (html lang="de") and on any element in another language. Screen readers pick their pronunciation rules from it, so 1.234,50 € is read as a German amount instead of as nonsense.
  • Use the time element with a machine-readable dateTime attribute next to formatted or relative text. Assistive tools and browser extensions can read the exact instant.
  • Prefer long forms where space allows. 3h ago or 1.2M can be read oddly by screen readers; 3 hours ago and 1.2 million (compactDisplay: "long") are clearer. If a narrow form is needed visually, put the long form in an aria-label.
  • Do not announce ticking clocks. A relative time that re-renders every minute must not sit inside an aria-live region, or the screen reader repeats it forever.

Check your understanding · Exercise 8

A table renders 2,000 rows, each calling price.toLocaleString(locale, { style: "currency", currency }) and date.toLocaleDateString(locale, opts). Scrolling is slow. What helps? Select all that apply.

Using it in a system design interview

Locale formatting rarely gets its own interview question, but it comes up inside many: a calendar, a chat app, a booking flow, a checkout. Raising it briefly and concretely signals that you have shipped to users in more than one country.

When to bring it up

  • In requirements, when the prompt mentions global users, multiple countries, or payments: ask which locales and currencies are in scope, and whether users in different zones see the same items (meetings, deadlines, auctions).
  • In the data model, when you write down entities: say out loud that instants are ISO 8601 UTC, plain dates are date strings, money is minor units plus an ISO code, and recurring events store a wall-clock time plus an IANA zone.
  • In the API, when you sketch responses: raw values, never formatted strings. Mention that the user's locale and zone live in their profile and come down with the session.
  • In rendering, if you choose SSR: name the hydration mismatch risk and your fix.
A wireframe of a booking card with five lines, each linked to the Intl API that produces it. Placed yesterday comes from RelativeTimeFormat. Wed, Mar 4 – Mon, Mar 9 comes from DateTimeFormat formatRange. Guests: Ana, Ben, and Chen comes from ListFormat. 5 nights comes from PluralRules inside a translated message. Total €1,234.50 comes from NumberFormat with a currency.Booking #4821Placed yesterdayRelativeTimeFormatWed, Mar 4 – Mon, Mar 9DateTimeFormat rangeGuests: Ana, Ben, and ChenListFormat5 nightsPluralRules (message)Total €1,234.50NumberFormat currency
Draw this in an interview when the design has a details card: every visible value has a formatter, and the API behind the card sends none of these strings.

If you have a minute at the whiteboard, draw a details card like the one above and point at each value: this comes from RelativeTimeFormat, this range from formatRange, this total from NumberFormat with the price's own currency. It shows in ten seconds that the API sends raw values and the client owns presentation.

How deep to go

LevelWhat a strong answer shows
JuniorUses Intl.DateTimeFormat and Intl.NumberFormat instead of string building; knows plurals differ by language and belong in translated messages.
Mid-levelStores UTC instants and minor units with ISO codes; separates locale from time zone; caches formatters; handles relative times and their refresh; knows plain dates are not instants.
SeniorDesigns for DST (recurring events, day boundaries), SSR consistency, ICU differences between runtimes, collation that matches the database, grapheme-safe limits on client and server, and tests that do not depend on whitespace.

Think first

An auction site shows "Ends Mar 8, 2:30 AM" to bidders around the world. What can go wrong, and how would you design it?

Summary

  • Never build user-visible dates, numbers, or plurals by concatenation; every value goes through an Intl formatter for the reader's locale.
  • Data travels raw: UTC instants, plain dates as dates, money as integer minor units plus an ISO 4217 code. Format only at the edge.
  • Locale and time zone are separate settings. Pass both explicitly; runtime defaults differ between your server, CI, and users.
  • Daylight saving makes some local times missing and others repeat. Use milliseconds for durations, zone-aware maths for calendar steps, and wall-clock time plus IANA zone for recurring events.
  • Plural categories differ by language (English 2, Polish 4, Arabic 6, Japanese 1). Let ICU messages and Intl.PluralRules choose, including ordinals.
  • Sort with Intl.Collator and cut text with Intl.Segmenter; .sort() and .slice() assume English and UTF-16.
  • Create formatters once and reuse them, and test against formatter output rather than hard-coded strings, because ICU data and whitespace change between versions.
  • With SSR, server and client must format with the same locale and zone, or render a stable value first, to avoid hydration mismatches.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Why is building a date or price string with template literals a problem, and what do you use instead?

Question 2

How do you store and display times for users in many time zones?

Question 3

Your SSR page shows a time that flickers after load. What is happening and how do you fix it?

Question 4

How would you show prices in a marketplace that sells in many currencies?

Question 5

A message input has a 280-character limit. How do you count?

Practice it

These design problems lean on dates, numbers, and plurals.

References

Read next