Formatting for display

Delegate to a locale-aware formatter, always with an explicit zone; never hand-assemble date strings.

The display half of Store vs display: once a value is stored correctly, rendering it for a human is its own concern with its own traps. The rule mirrors the storage one — formatting is presentation, kept separate from storage and from the wire format. Never hand-assemble a date string for a person: delegate to a locale-aware formatter, format as late as possible (in the rendering layer), and always format with an explicit zone. A formatter turns a precise value into a human-readable string, and which string it produces depends on two inputs you must supply deliberately — the locale and the zone.

Formatting is a zone-dependent operation

Formatting an instant is a zone-dependent operation — even when the output contains no visible time at all. An instant is a single point on the timeline; the calendar day it lands on depends on where you stand. Format the same instant in two zones and you can get two different dates:

const instant = new Date("2026-07-01T02:00:00Z");

new Intl.DateTimeFormat("en-US", {
  dateStyle: "long",
  timeZone: "America/New_York",
}).format(instant); // "June 30, 2026"

new Intl.DateTimeFormat("en-US", {
  dateStyle: "long",
  timeZone: "UTC",
}).format(instant); // "July 1, 2026"

At 02:00 UTC it is still the previous evening in New York, so “the date” is June 30 there and July 1 in UTC. Neither answer is wrong — they answer different questions. The bug is failing to ask the question: omit timeZone and the formatter falls back to the environment’s zone, which on a server is usually UTC and in a browser is wherever the user happens to be. Either way the day gets decided by an accident of deployment rather than a decision you made. Always pass the display zone explicitly.

Pitfall: Formatting an instant as a plain date without specifying a zone. The output silently uses the environment’s zone, so a UTC server shows the UTC day and a user far enough east or west sees the event on the wrong calendar day — the display-side twin of storing a civil date as a UTC instant.

Don’t hand-format; delegate to the locale

The other half of the rule: don’t build the string yourself. Hand-assembling a format hardcodes assumptions that are only true for your locale — the field order, the separators, the 12- versus 24-hour clock, the language of the month and weekday names:

// Don't: correct only for a slice of en-US readers
const s = `${m}/${d}/${y}`;

// Do: the locale decides order, separators, clock, and names
new Intl.DateTimeFormat("en-GB", {
  dateStyle: "medium",
  timeStyle: "short",
  timeZone: "Europe/London",
}).format(instant); // "1 Jul 2026, 03:00"

The same call under en-US renders Jul 1, 2026, 3:00 AM; under de-DE, 01.07.2026, 03:00. You wrote none of those differences — the locale data did. That is the point: choose the locale (from the user’s preference, not the server’s), choose the zone, and let the formatter produce the string.

Pitfall: Hardcoding a format — MM/DD/YYYY, an assumed AM/PM clock, English month names. It reads correctly to you and wrong to much of the world: 03/04 is March 4 or April 3 depending on the reader, and a 24-hour-clock locale never wanted “3:00 PM”. Pass a locale and options to a formatter instead of assembling the string yourself.

What the locale actually controls

A locale is more than a language tag; it is a bundle of formatting conventions. Handing one to Intl.DateTimeFormat selects, among other things:

  • Field order and separators2026-07-01 versus 01/07/2026 versus 01.07.2026.
  • Clock — 12-hour with AM/PM or 24-hour, and how midnight and noon are written.
  • Names — month, weekday, and era names in the locale’s language.
  • First day of the week — Sunday in the US, Monday across much of Europe (surfaced through the calendar and Intl.Locale APIs).
  • Calendar system — Gregorian by default, with Japanese, Buddhist, Hebrew, and others selectable, changing the year and era.

Relative time belongs to the same family. “3 hours ago” or “in 2 days” is a localized construction, not a string to build by hand:

new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-3, "hour");
// "3 hours ago"

All of this data comes from CLDR (the Unicode locale database) by way of ICU; you consume it, you do not maintain it.

Format for the human, not the machine

Locale formatting serves exactly one audience: a person reading a screen. Everything else — logs, API payloads, database columns, filenames, anything another program will parse — belongs in a machine format, which means ISO 8601 or RFC 3339, usually in UTC. A localized string is lossy and ambiguous to a parser (01/07/2026 has no single meaning), while an ISO instant round-trips exactly. Keep the two apart: store and transmit the machine format, and localize only at the very edge where the value meets a human. See Your own client ↔ server for the wire-format side of the same boundary.

Go deeper: CLDR/ICU data, dateStyle vs components, and server-side rendering

The output is not stable across platforms. Intl.DateTimeFormat and the various toLocale* methods read from CLDR through ICU, and the bundled CLDR/ICU version differs between Node releases, browsers, and language runtimes. The exact spacing, the choice of “AM” versus “am”, and narrow-format abbreviations can all shift between versions. Treat locale output as display-only: never assert on its exact bytes in a test, and never parse it back. If you need a stable string, format it yourself in a fixed machine format instead.

dateStyle/timeStyle versus explicit components. The dateStyle/timeStyle shorthands (short/medium/long/full) let the locale pick a sensible whole format and are the right default. Reach for explicit component options (year, month, day, hour, and so on) only when you need to control exactly which fields appear — and note the styles cannot be combined with individual component options in the same call.

Server-side rendering needs both the zone and the locale. When you format on the server (SSR, emails, PDFs), Intl defaults to the server’s environment — commonly UTC and en-US — not the user’s. Pass both the user’s zone and their locale explicitly, drawn from their stored preference, a request header (Accept-Language), or their session. This is the “resolve the display zone deliberately” discipline from Store vs display, extended to the locale.

See also. For where the value comes from and how it is stored, see Store vs display and Instant vs civil time. For why the zone — not just an offset — matters, see Time zones vs offsets. For the machine formats, see ISO 8601 and RFC 3339. For concrete per-language formatting APIs, see the language pages.


← Back to all topics