12k
All articles

Handling Time Zones in JavaScript Without Losing Your Mind

Handle JavaScript time zones with UTC instants, IANA zone IDs, Intl.DateTimeFormat, Temporal, and DST-safe rules for storing and displaying dates.

OpenReplay Team
OpenReplay Team
Handling Time Zones in JavaScript Without Losing Your Mind

Store and transmit every timestamp as a UTC instant in ISO 8601 (e.g. 2026-05-22T08:00:00Z), keep the IANA time-zone identifier (e.g. America/New_York) in a separate field, and convert to local time only at the moment of display — never store a wall-clock time without its zone. That single rule prevents most JavaScript time-zone bugs, and it holds whether you use Date, Intl, a library, or the new Temporal API.

This guide covers why the built-in Date makes time zones painful, the durable rules that fix the problem regardless of tooling, how to format dates correctly today with Intl.DateTimeFormat, what Temporal changes now that it has shipped in ES2026, which library to reach for in production as of June 2026, and the daylight-saving edge cases that cause the hardest-to-reproduce bugs.

Key Takeaways

  • Store and transmit instants in UTC (ISO 8601 or epoch), keep the IANA zone identifier in a separate field, and convert to local time only at display.
  • JavaScript’s Date has no named-time-zone support — it can only represent a moment in UTC or in the host machine’s zone, which is the root cause of “right date on my machine, wrong date for the user.”
  • A future event must be stored with its IANA zone, not as a fixed UTC instant, so it still resolves to the right wall-clock time if that region’s DST rules change before the date arrives.
  • As of June 2026, Temporal is a Stage 4 proposal in ECMAScript 2026, shipping natively in Firefox 139+, Chromium 144+, and Node.js 26+, but not in Safari — so production code still generally needs @js-temporal/polyfill or temporal-polyfill.
  • If you can’t ship Temporal yet, use Luxon 3.7.2 or date-fns 4.4.0 with @date-fns/tz — and note that the older date-fns-tz package targets date-fns v3, not v4.

Why JavaScript’s Date Makes You Lose Your Mind

The Date object has three structural flaws, and the third one is the time-zone killer. First, it is mutable: methods like setMonth and setFullYear modify the original object in place, so passing a Date into a function can silently change it for every other caller. Second, its numbering is inconsistent — months are zero-based (January is 0, December is 11) while days of the month are one-based — which produces off-by-one-month bugs that survive code review.

Third, and most consequential: Date has no real time-zone support. It can only represent a moment in UTC or in the host machine’s local zone, and nothing else — there is no way to construct or work with a Date “in America/New_York” the way you’d expect. The official TC39 proposal text lists this plainly: the venerable ECMAScript Date object has a number of challenges, including lack of immutability, lack of support for time zones, lack of support for use cases that require dates only or times only, a confusing and non-ergonomic API.

Host-zone rendering is why the same code shows the right date for a developer in Berlin and the wrong one for a user in Los Angeles. Consider an 8-line reproduction you can run with Node:

// repro.js — run with: TZ=America/Los_Angeles node repro.js
//          and again: TZ=Europe/Berlin node repro.js
const instant = new Date("2026-03-15T23:30:00Z"); // one fixed UTC moment
console.log(instant.toLocaleDateString());
// TZ=America/Los_Angeles → "3/15/2026"  (16:30 local, still the 15th)
// TZ=Europe/Berlin       → "3/16/2026"  (00:30 local, already the 16th)

One instant, two different calendar dates, depending only on the host’s zone. The bug is invisible to whoever wrote it because their machine sits in one zone. This host-zone date bug is the canonical “works on my machine” defect.

The Durable Rules That Fix Time Zones (Independent of Any Library)

The rules below prevent time-zone bugs no matter which API or library you use. They are the actual cure; the tools that follow are just different ways to apply them.

  1. Store and transmit instants in UTC. Persist timestamps as ISO 8601 with a Z suffix (2026-05-22T08:00:00Z) or as an epoch value. UTC is unambiguous and never shifts.
  2. Keep the IANA zone identifier in a separate field. A zone like Europe/London carries the DST rules an offset alone cannot. Store the identifier, not a raw offset like +01:00.
  3. Convert to local only at the edge — at display time. Keep everything in UTC through your storage, transport, and business logic; localize only in the view layer.
  4. Distinguish an absolute instant from a wall-clock-time-in-a-zone. A log entry or a “created at” value is an instant. A meeting on someone’s calendar is a wall-clock time bound to a zone. They are different data types and must be modeled differently.
  5. Store future events as zoned, not as a fixed UTC instant. This is the rule almost nobody states. If a user schedules a 9:00 AM meeting in America/New_York two years out and that region later changes its DST rules, a UTC timestamp frozen today resolves to the wrong wall-clock time. Storing the zone lets the instant be recomputed when the date arrives.

That last rule has a primary-source basis. The standard serialization Temporal uses, RFC 9557 (the Internet Extended Date/Time Format, published April 2024), exists precisely because, as Igalia notes, Temporal needs a standard way to serialize timestamps with timezone and calendar information, but the conventions in wide use — like appending IANA timezone names to timestamps — had never been on a formal standards track. MDN gives the operational version of the rule for offset versus named zones: avoid using offset identifiers if there is a named time zone you can use instead. Even if a region has always used a single offset, it is better to use the named identifier to guard against future political changes to the offset.

Localized Display Today With Intl.DateTimeFormat

For correct localized display right now, use Intl.DateTimeFormat with an explicit timeZone option. It is the one built-in that handles named zones correctly, it ships in every modern browser and Node, and it works hand-in-hand with both Date and Temporal.

const instant = new Date("2026-03-15T23:30:00Z");

new Intl.DateTimeFormat("en-US", {
  timeZone: "America/New_York",
  dateStyle: "full",
  timeStyle: "short",
}).format(instant);
// "Sunday, March 15, 2026 at 7:30 PM"

Passing timeZone explicitly is what makes this safe: you are no longer at the mercy of the host’s zone. Rendering the same instant for a different user means changing one string. This is the “convert at the edge” rule in code — keep the UTC instant everywhere, and let Intl do the localization in the view.

Temporal: The javascript date timezone Fix Built Into the Language

Temporal is the long-promised replacement for Date, and as of 2026 it is real. After 9 years of work, at the TC39 meeting of March 2026, Temporal officially reached Stage 4, making it part of ECMAScript 2026. The proposal repository confirms the status directly: this proposal is currently Stage 4. It will be merged into the ECMA-262 and ECMA-402 standards and this repository will be archived.

Temporal replaces Date with a namespace of immutable, purpose-specific types. The three you’ll use most:

  • Temporal.Instant — an exact moment in time (a nanosecond timestamp), with no calendar or zone. Use it for the UTC instants in rule 1.
  • Temporal.ZonedDateTime — an instant plus an IANA time zone plus a calendar. MDN describes it as a bridge between an exact time and a wall-clock time: it simultaneously represents an instant in history and a local, wall-clock time. It is the only Temporal class that is time zone-aware. Use it for zoned future events (rule 5).
  • Temporal.PlainDate / Temporal.PlainTime — a calendar date or a clock time with no zone at all, for things like birthdays and store-opening hours.

Arithmetic is immutable — every operation returns a new value — and converting a moment between zones is explicit:

const callAmsterdam = Temporal.ZonedDateTime.from(
  "2026-04-24T15:00:00[Europe/Amsterdam]"
);
const callNewYork = callAmsterdam.withTimeZone("America/New_York");
callNewYork.toString();
// "2026-04-24T09:00:00-04:00[America/New_York]"

Temporal also closes a sharp Date footgun: comparison operators on Temporal objects throw a TypeError by design, because in the absence of valueOf(), expressions with arithmetic operators such as plainDate1 > plainDate2 would fall back to being equivalent to plainDate1.toString() > plainDate2.toString(). Use Temporal.compare() or .equals() instead — Temporal.compare() ranks two zoned values by their underlying instant, so it treats 9:30 AM New York and 2:30 PM London as equal, whereas .equals() reports them as different because it also compares the time zone and calendar. For the full type list, see the MDN Temporal reference.

Temporal browser and runtime support (as of June 2026)

Temporal is shipping, but not everywhere. Native support landed in Firefox 139, which became the first browser to ship Temporal by default in May 2025, followed by Chrome 144 in January 2026. Edge runs on the same Chromium engine, and Node.js shipped it too: Node.js 26, released on May 5, 2026 with V8 14.6 and Undici 8, enabled Temporal without any flags or experimental settings. For the first time in JavaScript’s history, developers have a first-class date/time API built directly into the runtime.

The gap is Safari, which has not shipped it — and that is exactly why MDN marks Temporal as not yet Baseline. For cross-browser production code, you still need a polyfill. There are two: @js-temporal/polyfill, maintained by the proposal champions, and temporal-polyfill, a smaller, faster alternative by the FullCalendar team that provides cross-browser compatibility for the remaining browsers. Their official status in the proposal repo is alpha/beta rather than a stable 1.0, so pin a version and test before shipping. Verify the gzipped bundle size on Bundlephobia before budgeting for it; published figures vary widely.

Which Library to Use in Production Right Now

If you can’t rely on native Temporal across all your targets — and most production apps can’t until Safari ships and you’ve dropped the polyfill — reach for one of these. The verdict, as of June 2026:

ToolTime-zone aware?Immutable?Native today?Use when
Date + Intl.DateTimeFormatDisplay onlyNo (Date mutates)YesMinimal needs; formatting an existing instant
Luxon 3.7.2Yes (IANA)YesYesNew code wanting an ergonomic, immutable API close to Temporal
date-fns 4.4.0 + @date-fns/tzYes (IANA)YesYesTree-shakeable, function-per-import codebases
Day.js + utc/timezone pluginsYes (IANA)YesYesSmallest footprint; migrating off Moment.js
Temporal (native or polyfill)Yes (first-class)YesPartialControlled evergreen/Node environments, or with a polyfill

The most important accuracy trap is in the date-fns column. Time-zone support changed between major versions: starting from v4, date-fns has first-class support for time zones. It is provided via @date-fns/tz and @date-fns/utc packages. The v4 approach is the TZDate class and tz() helper from @date-fns/tz (v1.5.0). The older date-fns-tz package (v3.2.0) targets date-fns v3 and is explicit about it — its own docs say to use it if you’re looking for time zone support prior to date-fns v4. Don’t mix them.

The DST Edge Cases That Actually Bite

Daylight-saving time produces two failure modes, and both are under-tested in most codebases. In autumn (“fall back”), one local hour occurs twice, so a wall-clock time like 01:05 is ambiguous. In spring (“spring forward”), one local hour never exists at all, so a time like 02:05 is invalid.

Temporal resolves both deterministically. It is resolved using the disambiguation: “compatible” behavior: the later of the two possible instants will be used for time-skipped transitions and the earlier of the two possible instants will be used for time-repeated transitions. The outputs:

// Fall-back: 01:05 happens twice in New York on 2024-11-03
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]").toString();
// "2024-11-03T01:05:00-04:00[America/New_York]"  (default: the earlier instant)
Temporal.ZonedDateTime.from("2024-11-03T01:05:00[America/New_York]",
  { disambiguation: "later" }).toString();
// "2024-11-03T01:05:00-05:00[America/New_York]"  (the second occurrence)

// Spring-forward: 02:05 never exists in New York on 2024-03-10
Temporal.ZonedDateTime.from("2024-03-10T02:05:00[America/New_York]").toString();
// "2024-03-10T03:05:00-04:00[America/New_York]"  (default: skips forward an hour)

For the skipped hour you can also pass disambiguation: "reject" to throw instead of silently resolving — useful when a booking lands on a nonexistent time and you’d rather prompt the user than guess. With Date, none of this is handled for you, and the bug surfaces only for users in zones that observe DST, on the two days a year the transition happens.

Time-zone defects are hard to fix precisely because they don’t reproduce in the developer’s own zone. A countdown reads negative, an event card shows the wrong day, a booking lands on the wrong side of a DST boundary — but only for the user, never on the machine that wrote the code. Session replay is often the only practical way to close that gap: replaying a session captured in the user’s environment lets a developer in Europe/Berlin see the exact wrong date a user in America/Los_Angeles saw, rather than trying to imagine it.

Where to Go From Here

The cure for time-zone bugs isn’t a library — it’s the discipline of storing instants in UTC, keeping the IANA zone alongside, converting only at display, and modeling future events as zoned. Apply those rules first, then pick the tool: native Temporal where your environments support it, a polyfill where they don’t, and Luxon or date-fns v4 with @date-fns/tz for everything in between. Start by auditing one place in your codebase where a wall-clock time is stored without its zone — that field is almost certainly where your next off-by-a-day bug is waiting.

FAQs

Why does my date show a day off for some users but not for me?

A JavaScript Date stores only a UTC instant, and methods like toLocaleDateString render it in the host machine's zone. One fixed instant such as 2026-03-15T23:30:00Z renders as March 15 under America/Los_Angeles (16:30 local) but March 16 under Europe/Berlin (00:30 local). The code is correct; the calendar date differs because the render zone differs. This is why the bug never reproduces in the developer's own time zone.

Should I store a future meeting time as a UTC timestamp?

No. Store a future event as a wall-clock time bound to its IANA zone, such as 2026-05-22T09:00:00 with America/New_York kept alongside, not as a frozen UTC instant. If the region changes its daylight-saving rules between now and the event date, a UTC timestamp computed today resolves to the wrong wall-clock time, whereas the zoned value can be recomputed. UTC instants are correct for logs and past events, not future appointments.

What is the difference between date-fns-tz and @date-fns/tz?

They target different major versions and are not interchangeable. The older date-fns-tz package (v3.2.0) provides time-zone support for date-fns v3 only. Starting with date-fns v4, time-zone handling moved to the separate @date-fns/tz package (v1.5.0), which supplies the TZDate class and the tz helper. If you are on date-fns 4.x, use @date-fns/tz; mixing the two against the wrong major version is a common source of incorrect conversions.

Can I use the Temporal API in production in 2026?

Partially. As of June 2026, Temporal is a Stage 4 proposal in ECMAScript 2026 and ships natively in Firefox 139+, Chromium 144+ (Chrome and Edge), and Node.js 26+. Safari has not shipped it, which is why MDN marks Temporal as not yet Baseline. For controlled evergreen or server environments you can use it natively; for broad browser support you still need @js-temporal/polyfill or temporal-polyfill, both of which carry alpha or beta status, so pin a version and test first.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.