skip to content

A service holds timestamps as epoch milliseconds and must display each one in a chosen time zone. How do you render that with Intl.DateTimeFormat, and why is slicing or regexing the formatted string to rearrange it a bug?

level: seniorimportance: should knowfreq 40%

answer

  1. the instant is zone-agnostic
  2. zone chosen at format time
  3. styles and components cannot be mixed
  4. the output is locale data, not a fixed pattern
  5. typed parts instead of string surgery

basics

~20 s

Pass a timeZone option with an IANA identifier to Intl.DateTimeFormat and format the timestamp; the zone affects only rendering. Never parse the output — component order, separators and digits vary by locale. Use formatToParts to assemble a custom layout from typed pieces.

solid answer

~40 s

Construct `new Intl.DateTimeFormat(locale, { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' })` and call `format(ms)`. The timestamp is an instant; the `timeZone` option decides which wall-clock rendering of that instant you see, and an unknown zone identifier throws a RangeError. `Intl.DateTimeFormat().resolvedOptions().timeZone` tells you the environment's own zone, which is what you get if you pass nothing — fine in a browser, usually wrong on a server. Parsing the formatted string is fragile because everything about it is locale data: component order (`11/5/2024` vs `05/11/2024`), separators, day-period markers, non-Latin digits, and non-breaking spaces you cannot see. `formatToParts()` returns an array of `{ type, value }` objects — `year`, `month`, `day`, `literal` — so you can build any layout you need from labelled pieces instead of guessing at offsets.

code

javascript · 10 lines
javascript
const ms = 1700000000000;

const nyc = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York',
  dateStyle: 'medium',
  timeStyle: 'short',
});
console.log(nyc.format(ms)); // "Nov 14, 2023, 5:13 PM"

console.log(Intl.DateTimeFormat().resolvedOptions().timeZone); // host zone id

go deeper

for a junior

Know that Intl.DateTimeFormat takes a timeZone option with an IANA name like 'Europe/Berlin', and that the same timestamp renders differently per zone without changing the underlying value.

for a middle

Explain that styles and component options are mutually exclusive and that mixing them throws, that an unknown zone throws a RangeError, and that resolvedOptions().timeZone reports the environment's zone.

for a senior

Show why parsing formatted output is a defect — order, separators, digits and invisible spacing are all locale data — and reach for formatToParts. Be ready to say where the display zone comes from and why a server default is wrong.

for a principal

Own the contract: instants travel through storage and APIs, rendering happens at the edge, and the user's zone preference is data the system carries so that app pages, exports and notifications agree.

## The shape of the API ```js const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short', }); fmt.format(1700000000000); // "Nov 14, 2023, 5:13 PM" ``` `format` accepts a number of epoch milliseconds or a `Date`. The value identifies an *instant*; the `timeZone` option chooses which wall clock that instant is expressed on. Nothing about the input changes — rendering in Tokyo and in New York are two views of the same number. ## The timeZone option It takes `'UTC'` or an IANA identifier such as `'Europe/Berlin'`. An unrecognized identifier throws a **RangeError**, so validate before constructing if the value is user-supplied. Omit the option and you inherit the host's zone — read it explicitly when you need to know: ```js Intl.DateTimeFormat().resolvedOptions().timeZone; // e.g. "Europe/Berlin" ``` That is the correct way to discover the environment's zone. In a browser it is a reasonable default for the viewer; in a server process it is the container's configuration and has nothing to do with any user, which is why server-rendered timestamps must carry an explicit zone. A useful companion in ES2023 runtimes is `Intl.supportedValuesOf('timeZone')`, which returns the identifiers the runtime supports — handy for populating a picker instead of shipping a hardcoded list. ## Two option families that do not mix There are two ways to say what you want rendered: 1. **Styles** — `dateStyle` and `timeStyle`, each `'full' | 'long' | 'medium' | 'short'`. Locale-idiomatic and terse. 2. **Components** — `weekday`, `year`, `month`, `day`, `hour`, `minute`, `second`, `fractionalSecondDigits`, plus `timeZoneName` and `hour12` / `hourCycle`. Combining a style with any individual component option throws a **TypeError**. This trips people upgrading a formatter: adding `second: '2-digit'` to a `timeStyle` formatter does not refine it, it breaks it. Pick one family. `timeZoneName` accepts `'short'` and `'long'`, and in ES2022 runtimes also `'shortOffset'`, `'longOffset'`, `'shortGeneric'` and `'longGeneric'` — the offset forms are what you want when the label must be unambiguous. ## Why parsing the output is a defect Everything visible in the string is locale data: - **Order** differs — month/day/year in `en-US`, day/month/year in `en-GB`, year-first elsewhere. - **Separators** differ — `/`, `.`, `-`, or a localized word. - **Digits** differ — some locales default to a non-Latin numbering system. - **Spaces** are not always U+0020 — narrow no-break spaces appear around day-period markers and in grouped numbers, and the exact choice has changed between ICU data versions. So `out.split('/')[0]` is correct on your machine and wrong on a user's, and `/(\d+):(\d+) (AM|PM)/` fails on a string whose space is invisible-but-different. The intended escape hatch is `formatToParts`: ```js const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(Date.UTC(2024, 0, 5)); // [{type:'day',value:'05'}, {type:'literal',value:'/'}, ...] const get = (t) => parts.find((p) => p.type === t).value; `${get('year')}-${get('month')}-${get('day')}`; // "2024-01-05" ``` You get labelled pieces regardless of the locale's ordering, and you can drop, reorder or wrap any of them — which is also how you put a `<time>`-style span around just the day, or grey out the year. ## Ranges `formatRange` and `formatRangeToParts` render two instants as one idiomatic range ("5–8 Jan 2024"), collapsing the shared components the way the locale expects. Building that by formatting twice and joining with a dash produces "5 Jan 2024 – 8 Jan 2024", which no locale considers idiomatic. ## Cost and reuse Construction resolves locale and time-zone data and is the expensive step; `format` on an existing instance is cheap. A table of timestamps should build one formatter, not one per row. Cache by a key combining locale, zone and a stable serialization of the options. ## What stays out of the presentation layer Store and transmit the instant — epoch milliseconds or an ISO 8601 string with an offset — and format only at the point of display. A formatted timestamp in a database or an API payload is a rendering decision frozen into data: it cannot be re-rendered in another zone, cannot be compared, and cannot be sorted reliably.

  • What happens if you pass both dateStyle and an individual option such as day: '2-digit'?
    The constructor throws a TypeError. `dateStyle` and `timeStyle` form one option family and the individual components form another; they cannot be combined. This surprises people refining an existing formatter, since adding a component reads like a tweak but is actually a hard error. Choose the component family whenever you need fine control.
  • How would you build a stable "YYYY-MM-DD" string for a specific time zone?
    Use `formatToParts` with `year: 'numeric', month: '2-digit', day: '2-digit'` and the target `timeZone`, then read the parts by `type` and join them yourself. That is locale-order independent. Pair it with a locale that guarantees Latin digits, such as `'en-CA'` or the `'en-u-nu-latn'` tag, so a non-Latin default numbering system cannot leak into a machine-readable string.
  • Where should the display time zone come from in a multi-user product?
    From an explicit user preference, falling back to the browser's resolved zone via `Intl.DateTimeFormat().resolvedOptions().timeZone`, and never from the server's own configuration. Store the preference so server-rendered pages and emails match what the user sees in the app; a server default silently renders everyone's timestamps in the container's zone.

saying these in an interview costs you the question

  • Assuming the formatted string always has a fixed component order
  • Regexing format() output to extract the year or hour
  • Using the server's default zone for user-facing timestamps
  • Mixing dateStyle with individual component options
  • Storing formatted timestamps instead of the instant

context