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
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));
}
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
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.
KSTandEDTare aRangeError: Invalid time zone specifiedon this Node build. -
Etc/GMT+5is not UTC+5. The IANAetceterafile keeps POSIX signs, which count positive offsets west of Greenwich, soEtc/GMT+5is UTC−5. The same noon-UTC instant formats as07: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
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
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
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"));
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
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
- Always pass a tzdb city identifier (
America/New_York), neverEST/MST/KST. - Log
resolvedOptions().timeZone. Aliases are real: this Node build reportsAsia/Calcuttawhen you passAsia/Kolkata. - Prefer
timeZoneName: "shortOffset"when the offset is the thing the user needs to compare. - Set
hourCycleexplicitly, and do not also sethour12if you care which cycle you get. - Use
formatToParts()for structure. Pincalendar: "gregory"andnumberingSystem: "latn"when the values have to be numbers. - 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
-
MDN:
Intl.DateTimeFormat()constructor (hour12overrideshourCycle;timeZoneNamevalues and fallback) - MDN:
formatToParts() - MDN:
resolvedOptions() -
ECMA-402 (hour cycles;
h24maps hour 0 to 24) -
IANA tzdb
backward(EST→America/Panama,MST→America/Phoenix) -
IANA tzdb
etcetera(POSIX sign onEtc/GMT±N) - IANA: Time zone and daylight saving time data (abbreviations are not unique identifiers)
Top comments (0)