$ snippet timezone-dst-safe-dates
Convert dates across time zones (DST-safe)
Format any instant in a named IANA time zone, and get the current UTC offset for that zone — daylight saving handled by the browser.
/** Format an instant in a given IANA time zone, e.g. "America/New_York". */
function formatInZone(date, timeZone, locale = "en-US") {
return new Intl.DateTimeFormat(locale, {
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
timeZoneName: "short",
}).format(date);
}
/** UTC offset (in minutes) of a zone at a given instant. Accounts for DST. */
function zoneOffsetMinutes(date, timeZone) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
}).formatToParts(date).reduce((o, p) => (o[p.type] = p.value, o), {});
const asUTC = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
return Math.round((asUTC - date.getTime()) / 60000);
}
/** Build a Date from wall-clock components in a zone ("9:00 in Tokyo"). */
function dateFromZone(y, m, d, h, min, timeZone) {
const guess = new Date(Date.UTC(y, m - 1, d, h, min));
const offset = zoneOffsetMinutes(guess, timeZone);
const result = new Date(guess.getTime() - offset * 60000);
// second pass in case the guess straddled a DST switch
const offset2 = zoneOffsetMinutes(result, timeZone);
return offset2 === offset ? result : new Date(guess.getTime() - offset2 * 60000);
}How it works
A JavaScript Date is just a UTC timestamp; the time zone only matters when you format it or build it from parts. Intl.DateTimeFormat knows every zone's DST rules, so you never hard-code offsets like -5. The third helper solves the common "user entered 9:00 in their zone, store it as UTC" problem, including the day the clocks change.
Gotchas
- Never store or compare a fixed offset like
UTC-5— New York is -5 in winter and -4 in summer. Store the IANA zone name and a UTC instant. - Adding 24 hours is not the same as adding one day across a DST change (that day is 23 or 25 hours long). Add days via wall-clock parts, not milliseconds.
date.getTimezoneOffset()gives the browser's offset, not the user's chosen zone.- The
TemporalAPI (Temporal.ZonedDateTime) replaces all of this once it lands in all browsers.
Ad slot