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.
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. Anson the end is wrong in almost every language. - Time zones. A timestamp of
15:30 UTCon March 4 is9:00 PMin India and7:30 AMin 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.
// 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 })
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.
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
- The value: a
Date(or epoch milliseconds), anumberorbigint, a list of strings, a count. - 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. - 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
| Kind of value | Send this | Not 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 zone | Midnight 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 count | 3 | "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:
| Style | dateStyle in en-US | timeStyle in en-US | dateStyle in de-DE |
|---|---|---|---|
full | Wednesday, March 4, 2026 | 3:30:00 PM Coordinated Universal Time | Mittwoch, 4. März 2026 |
long | March 4, 2026 | 3:30:00 PM UTC | 4. März 2026 |
medium | Mar 4, 2026 | 3:30:00 PM | 04.03.2026 |
short | 3/4/26 | 3:30 PM | 04.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
dateStyletogether withhourthrows aTypeError. Pick styles or fields per formatter. hourCycleforces a clock:h23gives15:30even inen-US. Use it only when the user chose a 24-hour clock in your settings; otherwise let the locale decide.timeZoneNameadds the zone: inen-US,shortgivesESTorGMT+5:30,shortOffsetalways gives an offset such asGMT-5, andlonggivesCentral European Standard Time. It is a field, so it cannot be combined withdateStyleortimeStyleeither. 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-THprints the Buddhist year (4 มี.ค. 2569),fa-IRthe Persian calendar (۱۳ اسفند ۱۴۰۴), andja-JP-u-ca-japanesethe 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.
type is day in strong bolds the right piece in every locale without rebuilding the string.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>
);
}
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.
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.
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.
// 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" });
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:
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.
| Options | Locale | Output |
|---|---|---|
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-US | KWD 1,234.500 |
currency: "CAD" | en-US | CA$1,234.50 |
currency: "USD", currencyDisplay: "name" | en-US | 1,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), orname.
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
minimumFractionDigitsandmaximumFractionDigitscontrol decimals.{ minimumFractionDigits: 2 }turns5into5.00.maximumSignificantDigits: 3turns123456into123,000.- Rounding is decimal, not binary.
(1.005).toFixed(2)gives"1.00"because of float error, whileNumberFormatwith two decimals gives1.01. The default rounds half away from zero (2.5becomes3);roundingMode: "halfEven"gives banker's rounding (2.5becomes2). signDisplay: "exceptZero"shows+5and0, which suits change indicators such as+4%on a dashboard.formatRange(3, 5)prints$3.00 – $5.00for 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
| Locale | Categories it uses | 0 | 1 | 2 | 5 | 21 | 22 | 1.5 |
|---|---|---|---|---|---|---|---|---|
en | one, other | other | one | other | other | other | other | other |
fr | one, many, other | one | one | other | other | other | other | one |
pl | one, few, many, other | many | one | few | many | many | few | other |
ru | one, few, many, other | many | one | few | many | one | few | other |
ar | all six | zero | one | two | few | many | many | other |
ja | other | other | other | other | other | other | other | other |
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.
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.
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"
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.
| Locale | format(-1, "day") | format(-3, "hour") | format(2, "day") |
|---|---|---|---|
en | yesterday | 3 hours ago | in 2 days |
de | gestern | vor 3 Stunden | übermorgen |
es | ayer | hace 3 horas | pasado 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.
| API | Output |
|---|---|
| Loading | Formatting 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: truesorts digits by value:item1, item2, item10instead ofitem1, item10, item2. Use it for file names, versions, and anything with numbers inside.sensitivity: "base"ignores case and accents when comparing, soresumeequalsRésumé(compare returns0). Combine it withusage: "search"for a forgiving filter box.caseFirst: "upper"putsAbeforeawhen they would otherwise tie.- Reuse it.
localeCompare(other, locale, options)can build a collator per call, liketoLocaleDateString(V8 caches only the simplest calls). For a list of 10,000 names, create oneCollatorand pass itscomparetosort.
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.
.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.
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 👨👩👧👦…"
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.
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?
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=UTCand 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), whilenew 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 prints3/4/2026, 3:30:00 PMon a UTC server and3/4/2026, 9:00:00 PMon a laptop in India. Always pass a locale and, for times, atimeZone.
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
formatcall, as the Node test in chapter 3 showed. - Avoid polyfills you do not need.
DateTimeFormat,NumberFormat,PluralRules,RelativeTimeFormat,ListFormat,Collator, andSegmenterare 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(for1 hr, 30 minand timer-style1: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-numsso lists do not jump, which helps Cumulative Layout Shift.
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 €"
Accessibility
- Set
langon the page (html lang="de") and on any element in another language. Screen readers pick their pronunciation rules from it, so1.234,50 €is read as a German amount instead of as nonsense. - Use the
timeelement with a machine-readabledateTimeattribute next to formatted or relative text. Assistive tools and browser extensions can read the exact instant. - Prefer long forms where space allows.
3h agoor1.2Mcan be read oddly by screen readers;3 hours agoand1.2 million(compactDisplay: "long") are clearer. If a narrow form is needed visually, put the long form in anaria-label. - Do not announce ticking clocks. A relative time that re-renders every minute must not sit inside an
aria-liveregion, 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.
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
| Level | What a strong answer shows |
|---|---|
| Junior | Uses Intl.DateTimeFormat and Intl.NumberFormat instead of string building; knows plurals differ by language and belong in translated messages. |
| Mid-level | Stores 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. |
| Senior | Designs 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?
Store the end as a UTC instant, which is unambiguous, and render it in each bidder's zone with timeZoneName so nobody misreads it.
If the seller picked the end time as a wall-clock time in New York, convert it to an instant when saving and validate it: 2:30 AM on March 8, 2026 does not exist there, so reject it or ask the seller.
Show a countdown computed from the instant (a duration, not calendar maths), and refresh it on a timer. With SSR, render the absolute UTC-based time or the user's saved zone so hydration matches.
Summary
- Never build user-visible dates, numbers, or plurals by concatenation; every value goes through an
Intlformatter 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.PluralRuleschoose, including ordinals. - Sort with
Intl.Collatorand cut text withIntl.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?
Order, separators, symbols, and digits all vary by locale: 3/4/2026 is April 3 in Europe, Germany writes 1.234,50 €, India groups as 12,34,567, and Arabic locales may use different digits. Hand-built strings are correct for one country.
I keep values raw and format them at render time with Intl.DateTimeFormat and Intl.NumberFormat, configured with the user's locale and time zone, and I put whole sentences with their plurals in ICU messages so translators control word order.
Question 2
How do you store and display times for users in many time zones?
Instants go in UTC as ISO 8601 or epoch milliseconds, and the client formats them with the user's IANA zone via the timeZone option. Plain dates such as birthdays are stored as dates without a time, so they never shift.
Recurring events store a wall-clock time plus the IANA zone, because adding fixed hours breaks across daylight saving changes. Durations use milliseconds; calendar steps use zone-aware maths, ideally with Temporal or a library until Temporal is everywhere.
Question 3
Your SSR page shows a time that flickers after load. What is happening and how do you fix it?
The server formatted with its own default zone, usually UTC, and the browser formatted with the user's zone, so the hydrated text differs and React replaces it.
Either pass the user's saved zone and locale to formatters on both sides, or render a stable absolute value on the server and switch to local or relative text after mount with reserved width. Suppressing the warning alone hides the symptom.
Question 4
How would you show prices in a marketplace that sells in many currencies?
Each price carries integer minor units and an ISO 4217 code from the seller. The reader's locale decides formatting and the price's currency decides symbol and decimals, using Intl.NumberFormat with style: "currency".
In mixed-currency views I use currencyDisplay: "code" to avoid ambiguous $ signs, and conversion, if offered, is a separate, clearly labelled estimate.
Question 5
A message input has a 280-character limit. How do you count?
By graphemes with Intl.Segmenter, because .length counts UTF-16 code units: one family emoji is 11. Truncation also cuts at grapheme boundaries so emoji and accents never break.
The server must use the same definition and a matching Unicode version, or it will reject text the client accepted.
Practice it
These design problems lean on dates, numbers, and plurals.
- Design a countdown timerEasy
- Design a like button for millions of usersEasy
- Design report exportEasy
- Design a date pickerMedium
- Design an emoji and GIF pickerMedium
- Design a notification centerMedium
- Design an embeddable poll widgetMedium
- Design an e-commerce cart and checkoutMedium
- Design a CSV import with column mappingMedium
- Design an internationalization systemMedium
- Design a real-time leaderboardMedium
- Design a live stock price tickerMedium
- Design a grocery delivery slot pickerMedium
- Design a scheduling link like CalendlyMedium
- Design a calendar week viewHard
- Design AirbnbHard
- Design a flight booking siteHard
- Design Google CalendarHard
- Design an analytics dashboardHard
- Design a stock trading appHard
References
- MDN: Intl Every Intl constructor with its options and browser support.
- MDN: Intl.DateTimeFormat Styles, fields, time zones, formatToParts, and formatRange.
- MDN: Intl.NumberFormat Currency, units, compact notation, and rounding options.
- MDN: Temporal The new date and time API and its current browser support.
- Unicode CLDR: Language Plural Rules The cardinal and ordinal categories for every language.
- ECMA-402: ECMAScript Internationalization API Specification The specification behind every Intl API.
- IANA Time Zone Database The source of zone names like America/New_York and their rule history.
Read next
- Translating an appHow an app speaks many languages: text in per-locale message catalogs, ICU messages so plurals and gender work everywhere, catalogs loaded per locale and route, clear fallbacks, and a safe workflow with translators.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it. - Locales, routing, and SEOHow a multilingual site picks a language, gives every language its own URL, tells search engines about the copies with hreflang, renders and caches each locale on the server, keeps translation bytes small, and copes with German text that runs 30% longer.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.