DEV Community

Nomad Wozo
Nomad Wozo

Posted on

Intl.DateTimeFormat Gotchas When Showing a City's Local Time

I was wiring a small world clock: given one instant, show the wall time in New York, London, and Seoul. Intl.DateTimeFormat is the right API for this — you pass an IANA time zone and a locale, and the runtime does the offset and DST math. The surprises sit in the options that look obvious: timeZone: "EST", timeZoneName: "short", and hour12 next to hourCycle.

Every snippet below was run on Node.js v24.22.1 (ICU 78.3, CLDR 48, tzdb 2026e). The process default time zone on this machine is Asia/Seoul.

Date#getHours() is the host's hour, not the city's

A Date stores an instant (milliseconds since the Unix epoch). getHours(), getDate(), and toString() convert that instant in the runtime zone. They will not give you New York's clock just because you are thinking about New York.

const instant = new Date("2026-11-14T12:00:00Z"); // noon UTC

console.log(instant.getHours());     // 21  (Seoul, UTC+9)
console.log(instant.getUTCHours());  // 12
console.log(instant.toString());
// Sat Nov 14 2026 21:00:00 GMT+0900 (Korean Standard Time)

const fmt = new Intl.DateTimeFormat("en-US", {
  timeZone: "America/New_York",
  hour: "numeric",
  minute: "2-digit",
  timeZoneName: "short",
});
console.log(fmt.format(instant)); // 7:00 AM EST
Enter fullscreen mode Exit fullscreen mode

If you omit timeZone, the formatter uses the same default as Date.prototype.toString() — here, Asia/Seoul. Always pass the city zone you mean.

"EST" is not Eastern Time. In tzdb it is Panama.

This is the bug that looks right in November and wrong in July. Pass timeZone: "EST" and you might assume you will get US Eastern Time, with daylight saving in the summer. You will not.

On this runtime, EST canonicalizes to America/Panama. Panama has been on UTC−5 without DST for a long time, and IANA's backward file links the old System V name EST there. MST is the same class of footgun: it links to America/Phoenix (Arizona, no DST), not America/Denver.

const summer = new Date("2026-07-14T16:00:00Z");

for (const timeZone of ["EST", "America/New_York", "MST", "America/Denver"]) {
  const fmt = new Intl.DateTimeFormat("en-US", {
    timeZone,
    month: "short",
    day: "numeric",
    hour: "numeric",
    minute: "2-digit",
    hourCycle: "h23",
    timeZoneName: "shortOffset",
  });
  console.log(timeZone, fmt.resolvedOptions().timeZone, fmt.format(summer));
}
Enter fullscreen mode Exit fullscreen mode
EST               America/Panama    Jul 14, 11:00 GMT-5
America/New_York  America/New_York  Jul 14, 12:00 GMT-4
MST               America/Phoenix   Jul 14, 09:00 GMT-7
America/Denver    America/Denver    Jul 14, 10:00 GMT-6
Enter fullscreen mode Exit fullscreen mode

At that instant New York is on EDT (UTC−4). The EST formatter is still on UTC−5, so it is an hour behind the city you thought you asked for. resolvedOptions().timeZone is how you see the alias the runtime actually used.

Three more naming traps:

  • Abbreviations are not unique identifiers. IANA's own guidance is not to use them that way — in English-speaking North America "CST" is US Central Standard Time; in China it is China Standard Time.
  • Some abbreviations just throw. KST and EDT are a RangeError: Invalid time zone specified on this Node build.
  • Etc/GMT+5 is not UTC+5. The IANA etcetera file keeps POSIX signs, which count positive offsets west of Greenwich, so Etc/GMT+5 is UTC−5. The same noon-UTC instant formats as 07:00 GMT-5.

Use a city identifier from the tz database: America/New_York, America/Denver, Asia/Seoul. Offset-only IDs like "-05:00" are valid in Intl, but they do not follow DST.

timeZoneName: "short" is a localized label, not a stable code

Even after you pass a real IANA zone, timeZoneName: "short" is not a reliable ticker. It is a localized string, and the exact text is implementation- and locale-dependent. Formatting noon UTC on 2026-11-14 in America/New_York:

Locale short shortOffset shortGeneric
en-US EST GMT-5 ET
en-GB GMT-5 GMT-5 New York Time
fr-FR UTC−5 UTC−5 heure : New York
ko-KR GMT-5 GMT-5 뉴욕 시간

en-GB does not print EST. fr-FR uses UTC and a minus sign (U+2212), not GMT-5. en-US has no short abbreviation for Asia/Seoul — the same options print 9:00 PM GMT+9, not KST. In July, Europe/London at 2026-07-14T16:00:00Z is 5:00 PM GMT+1 in en-US but 17:00 BST in en-GB.

MDN notes that time zone display may fall back to another format when a preferred string is unavailable. That is why shortGeneric in en-GB becomes New York Time rather than ET.

If you are building a world clock and you want the offset to be comparable across locales, prefer timeZoneName: "shortOffset" or "longOffset", and pull the label out with formatToParts() instead of scraping the formatted string.

hour12 silently wins, and midnight has four spellings

ECMA-402 defines four hour cycles: h11 and h23 run 0–11 and 0–23; h12 and h24 run 1–12 and 1–24. The difference is obvious at local midnight and noon. For America/New_York on 2026-11-14T05:00:00Z (midnight) and 2026-11-14T17:00:00Z (noon):

        midnight                  noon
h11     Sat, Nov 14, 00:00 AM     Sat, Nov 14, 00:00 PM
h12     Sat, Nov 14, 12:00 AM     Sat, Nov 14, 12:00 PM
h23     Sat, Nov 14, 00:00        Sat, Nov 14, 12:00
h24     Sat, Nov 14, 24:00        Sat, Nov 14, 12:00
Enter fullscreen mode Exit fullscreen mode

h24 keeps the calendar date and prints 24:00. That is what the spec says to do with hour 0; it does not roll the date back to November 13.

If you also pass hour12, it overrides hourCycle. MDN is explicit, and the runtime agrees:

const fmt = new Intl.DateTimeFormat("en-US", {
  timeZone: "America/New_York",
  hour: "numeric",
  minute: "2-digit",
  hourCycle: "h23",
  hour12: true,
});
console.log(fmt.resolvedOptions().hourCycle); // h12
console.log(fmt.format(new Date("2026-11-14T05:00:00Z"))); // 12:00 AM
Enter fullscreen mode Exit fullscreen mode

The locale decides which 12-hour cycle you get. hour12: true on en-US becomes h12 (12:00 AM). On ja-JP it becomes h11 (午前0:00). Locale defaults without either option also differ: en-US and ko-KR use h12; en-GB and ja-JP use h23.

If you care about the cycle, set hourCycle and leave hour12 unset.

Don't parse format(), and don't Number() Arabic digits

format() is a display string. Field order, separators, calendar, and digits all follow the locale:

en-US  11/14/2026, 07:00
en-GB  14/11/2026, 07:00
ko-KR  2026. 11. 14. 07:00
th-TH  14/11/2569 07:00           // Buddhist calendar (year 2569)
ar-EG  ١٤/١١/٢٠٢٦، ٠٧:٠٠         // Arabic-Indic digits
Enter fullscreen mode Exit fullscreen mode

Number("07") is 7. Number("٠٧") is NaN. th-TH will happily hand you a year you can add to, except it is 543 years ahead of the Gregorian year.

formatToParts() gives you named fields. If those fields are going into arithmetic — sorting rows, computing an offset — pin calendar: "gregory" and numberingSystem: "latn" so the values are ASCII digits in the Gregorian calendar. Leave the user's locale for the string you actually render.

dateStyle / timeStyle cannot be combined with individual options such as hour or timeZoneName — that is a TypeError. Ask for the style or the parts, not both.

A helper that returns numbers, not a sentence

Here is the pattern I ended up with for "what is the wall clock in this city?":

function cityWallTime(date, timeZone) {
  const fmt = new Intl.DateTimeFormat("en-US", {
    timeZone,
    weekday: "short",
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hourCycle: "h23",
    timeZoneName: "shortOffset",
    calendar: "gregory",
    numberingSystem: "latn",
  });

  const parts = Object.fromEntries(
    fmt.formatToParts(date)
      .filter((p) => p.type !== "literal")
      .map((p) => [p.type, p.value]),
  );

  const asUTC = Date.UTC(
    Number(parts.year),
    Number(parts.month) - 1,
    Number(parts.day),
    Number(parts.hour),
    Number(parts.minute),
    Number(parts.second),
  );

  return {
    timeZone,
    weekday: parts.weekday,
    year: Number(parts.year),
    month: Number(parts.month),
    day: Number(parts.day),
    hour: Number(parts.hour),
    minute: Number(parts.minute),
    second: Number(parts.second),
    timeZoneName: parts.timeZoneName,
    offsetMinutes: (asUTC - date.getTime()) / 60_000,
  };
}

const instant = new Date("2026-11-14T12:00:00Z");
console.log(cityWallTime(instant, "America/New_York"));
console.log(cityWallTime(instant, "Europe/London"));
console.log(cityWallTime(instant, "Asia/Seoul"));
console.log(cityWallTime(instant, "Pacific/Auckland"));
Enter fullscreen mode Exit fullscreen mode
America/New_York  Sat 2026-11-14 07:00:00  GMT-5  offsetMinutes: -300
Europe/London     Sat 2026-11-14 12:00:00  GMT+0   offsetMinutes: 0
Asia/Seoul        Sat 2026-11-14 21:00:00  GMT+9   offsetMinutes: 540
Pacific/Auckland  Sun 2026-11-15 01:00:00  GMT+13  offsetMinutes: 780
Enter fullscreen mode Exit fullscreen mode

Auckland is already Sunday. That is the whole reason to do this with a named zone and parts, not with getHours() and a hardcoded offset.

The offset trick is: format the instant in the city, then interpret those civil fields as if they were UTC. The millisecond difference is the offset. It also holds for half-hour and 45-minute zones: Asia/Kolkata is 330 minutes year-round here, and Pacific/Chatham is 765 minutes on 2026-07-14 and 825 on 2026-11-14.

Checklist

  1. Always pass a tzdb city identifier (America/New_York), never EST / MST / KST.
  2. Log resolvedOptions().timeZone. Aliases are real: this Node build reports Asia/Calcutta when you pass Asia/Kolkata.
  3. Prefer timeZoneName: "shortOffset" when the offset is the thing the user needs to compare.
  4. Set hourCycle explicitly, and do not also set hour12 if you care which cycle you get.
  5. Use formatToParts() for structure. Pin calendar: "gregory" and numberingSystem: "latn" when the values have to be numbers.
  6. Store UTC instants (or offset-aware strings). Format at the edge.

I ran into these while building a small city-to-city converter, WorldTimeSync: https://time.wooooju.com/en/

Sources

Top comments (0)