How to Handle Timezones and UTC in JavaScript
Timezones trip up almost every developer at some point, mostly because
Date objects don't actually "have" a timezone — internally, every
Date is just a millisecond count since the Unix epoch (UTC). Timezones
only enter the picture when you format or parse a date. Once you
internalize that, most of the confusion goes away.
1. Detect the visitor's timezone
No library needed — the browser already knows:
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// e.g. "America/Argentina/Buenos_Aires"2. Format a date in any timezone
Pass a timeZone to Intl.DateTimeFormat and it handles the
conversion, including daylight saving, for you:
new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true,
}).format(new Date());
// "3:45:12 PM"3. Get a timezone's current UTC offset
Useful for labels like "UTC-5" or converting between two zones:
function getOffsetLabel(timeZone, date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone, timeZoneName: "shortOffset",
}).formatToParts(date);
return parts.find(p => p.type === "timeZoneName").value; // "GMT-5"
}4. Convert a wall-clock time from one zone to another
This is the part people usually reach for a library for, but it's doable with just the offset above: build a UTC timestamp from the target date/time, then subtract the source zone's offset.
function convert(zoneA, hh, mm, refDate) {
// Y/M/D "today" as seen in zoneA
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: zoneA, year: "numeric", month: "2-digit", day: "2-digit",
}).formatToParts(refDate);
const get = t => +parts.find(p => p.type === t).value;
const offsetMin = /* parse "-300" from getOffsetLabel(zoneA, refDate) */ -300;
const utcMs = Date.UTC(get("year"), get("month") - 1, get("day"), hh, mm) - offsetMin * 60000;
return new Date(utcMs); // format this in zoneB to get the converted time
}This is exactly the technique behind our own Meeting Planner tool — worth a look if you'd rather not write the conversion logic yourself.
5. The one rule that prevents most bugs
Store and transmit dates in UTC (ISO 8601, e.g. 2026-07-22T14:30:00Z),
and only convert to a local timezone at the last moment — when you render it for a
human. Databases, APIs, and logs should never store "local time" without an
offset attached; that ambiguity is where most timezone bugs come from.
6. Detect whether a timezone observes daylight saving at all
You don't need a lookup table for this either — compare a zone's UTC offset in mid-January to its offset in mid-July. If they match, that zone doesn't observe daylight saving; if they differ, you also know which offset is currently active. This is the exact technique behind the live status on our own DST Tracker:
function offsetMinutes(timeZone, date) {
const label = getOffsetLabel(timeZone, date); // from step 3, e.g. "GMT-5"
const m = /GMT([+-])(\d+)(?::(\d+))?/.exec(label);
const sign = m[1] === "-" ? -1 : 1;
return sign * (parseInt(m[2]) * 60 + (m[3] ? parseInt(m[3]) : 0));
}
const year = new Date().getFullYear();
const jan = offsetMinutes("Europe/London", new Date(year, 0, 15));
const jul = offsetMinutes("Europe/London", new Date(year, 6, 15));
const observesDst = jan !== jul; // true for London, false for TokyoCommon Pitfalls
A handful of mistakes account for most timezone bugs in JavaScript, and none of them need a library to avoid:
- Date strings without a "Z" or offset are parsed as local time, not UTC.
new Date("2026-07-22T14:30:00")is interpreted in the visitor's own timezone, whilenew Date("2026-07-22T14:30:00Z")is UTC — an easy one-line bug that silently shifts every timestamp by the visitor's offset. getMonth()is zero-indexed. January is0, December is11. This is a decades-old JavaScript design choice, not a bug, but it still catches people off guard when building dates by hand.- An hour can be skipped or repeated during a DST transition. On the
"spring forward" date, the local time 2:30 AM might not exist at all; on "fall back," it
can happen twice. Code that builds a
Datefrom raw local hour/minute values without accounting for this can silently land on the wrong side of the transition. toLocaleString()without atimeZoneoption uses the server's or browser's local zone, not any zone you intended. Always pass an explicittimeZonewhen formatting for a specific place, especially in server-side Node.js code where "local" means the server's zone, not the user's.
Do you need a library at all?
For most apps, no — Intl and Date cover formatting,
conversion, and offset lookups natively in every modern browser and in Node.js. Reach
for a library like date-fns-tz or Luxon only if you need
heavier date-math (adding business days, recurring events, etc.) — not just timezone
display.