Working with ZonedDateTime Objects
Temporal.ZonedDateTime is the one Temporal type that knows everything about a moment: the exact instant, the wall-clock time a human reads, and the IANA zone that ties them together. Part of Modern Date Logic with the Temporal API. If you are new to Temporal, skim Getting Started with Temporal API before diving in here.
What breaks without it
Without a zone-aware type, the moment you try to reason about "what time is it there" you are forced into one of two bad options: store a bare wall-clock string and lose the instant it corresponds to, or store a UTC instant and lose the zone it was meant to be read in. Legacy Date pushes you toward the second, because it is a UTC instant with locale-dependent display methods and no slot for an IANA zone. So applications bolt a zone name onto a separate column and hope the two never drift, or they hard-code offsets that are correct for only half the year. Both approaches leak wrong times at daylight-saving boundaries.
ZonedDateTime exists precisely to hold all three facts a scheduled time needs at once — the instant, the wall-clock reading, and the named zone that ties them together — so that no operation has to reconstruct a missing piece by guessing. That completeness is what lets it convert between zones exactly, do arithmetic that respects transitions, and serialize to a single self-describing string. The bugs that plague zone handling in legacy code are, almost without exception, symptoms of a value that was missing one of those three facts.
The legacy Date object stores a single UTC number and borrows the host machine's timezone for every read. That works until you schedule a meeting for "9 AM in Berlin" on a server running in UTC, add a day across a spring-forward transition, or render a timestamp during server-side rendering where the host zone differs from the user's. The displayed time drifts by an hour, recurring events land in the wrong slot, and hydration mismatches flash the wrong value. ZonedDateTime removes the ambiguity by carrying the zone with the value and applying real DST rules to every calculation.
The hardest idea on this page: wall-clock days vs. absolute hours
The single concept that unlocks correct zoned arithmetic is that "one day later" and "twenty-four hours later" are different operations, and they diverge exactly on daylight-saving days. Adding { days: 1 } to a ZonedDateTime keeps the same wall-clock time on the next calendar day — 9am today to 9am tomorrow — even when the intervening night was 23 or 25 hours long. Adding { hours: 24 } moves exactly 24 hours of physical time, which on a spring-forward day lands at 10am and on a fall-back day at 8am. Neither is "more correct"; they answer different questions, and a scheduler must know which one it means.
Most human scheduling is wall-clock: a daily standup is at 9am regardless of whether the clocks shifted overnight, and a subscription renews on "the 1st" not "every 720 hours." So { days: 1 }, { months: 1 }, and their calendar kin are usually what you want. Absolute-hour arithmetic is for elapsed-time reasoning — timeouts, SLA windows, "two hours from now" — where the physical duration is the point. ZonedDateTime keeps both available and distinct, which is exactly the distinction legacy millisecond math erases by treating every day as 86,400,000 milliseconds.
A ZonedDateTime is an instant + an IANA timeZone + a calendar. The subtle part is how arithmetic behaves across a DST boundary. .add({ days: 1 }) preserves the wall-clock time — 9 AM stays 9 AM even though the real elapsed time was 23 or 25 hours. .add({ hours: 24 }) adds absolute time — exactly 24 hours of physical duration, which can land on a different wall-clock hour. The diagram below shows both paths across the US spring-forward night.
API reference
A ZonedDateTime is constructed from a wall-clock string plus a bracketed IANA zone ('2024-03-15T09:00:00-04:00[America/New_York]'), or by attaching a zone to a PlainDateTime via toZonedDateTime, or by reading Temporal.Now.zonedDateTimeISO(zone). Once built, it exposes the full set of civil fields (year, month, day, hour, dayOfWeek, …) read in its own zone, plus epochNanoseconds and toInstant() for the absolute moment, and offset/timeZoneId for the zone facts. The point is that every projection you might need is one accessor away, with no external zone database call in your own code.
The operations that make it powerful are withTimeZone (same instant, new zone lens), startOfDay (first valid instant of the civil day, DST-aware), round (snap to a unit, respecting transitions), and the add/subtract/until/since arithmetic that distinguishes calendar units from clock units as described above. Serialization is toString() producing the self-describing bracketed form, and Temporal.ZonedDateTime.from() reconstructs it losslessly, with an offset disambiguation option for the rare case where a stored offset conflicts with the zone's current rules.
| Member | Signature | Returns | Timezone caveat |
|---|---|---|---|
Temporal.ZonedDateTime.from() |
from(item, opts?) |
ZonedDateTime |
String must carry [IANA/Zone]; bare local times throw |
Temporal.Now.zonedDateTimeISO() |
zonedDateTimeISO(tz?) |
ZonedDateTime |
Defaults to host zone — pass explicit tz on servers |
.add() / .subtract() |
add(Duration | object, opts?) |
ZonedDateTime |
Calendar units keep wall-clock; time units add absolute |
.until() / .since() |
until(other, opts?) |
Duration |
Operates on UTC instants — DST-safe |
.equals() |
equals(other) |
boolean |
Compares exact instant, not wall-clock |
.withTimeZone() |
withTimeZone(tz) |
ZonedDateTime |
Same instant, re-projected to a new zone |
.toInstant() / .toPlainDateTime() |
— | Instant / PlainDateTime |
Drops zone or instant, respectively |
.offset / .offsetNanoseconds |
property | string / number |
Reflects DST at that moment, not a fixed value |
Approach A: the legacy Date solution and why it falls short
// "Add one day to a 9 AM New York appointment" with legacy Date.
const appt = new Date('2024-03-09T09:00:00-05:00'); // 9 AM EST
const nextDay = new Date(appt.getTime() + 24 * 60 * 60 * 1000); // add 24h of ms
// In a process running in America/New_York, this reads as 10:00 AM, not 9:00 AM,
// because 2024-03-10 lost an hour to spring-forward. Date cannot keep the clock.
console.log(nextDay.toString());
Date only knows how to add absolute milliseconds. It has no concept of "the same wall-clock time tomorrow," and the result it displays depends on the host machine's zone. There is no safe way to express calendar-aware arithmetic with Date alone.
Approach B: the Temporal solution
import { Temporal } from '@js-temporal/polyfill';
const appt = Temporal.ZonedDateTime.from(
'2024-03-09T09:00:00-05:00[America/New_York]'
);
// Calendar unit: keep the wall clock at 09:00 the next day.
const sameClock = appt.add({ days: 1 });
console.log(sameClock.toString());
// '2024-03-10T09:00:00-04:00[America/New_York]' — still 9 AM, now EDT
// Time unit: add exactly 24 hours of real elapsed time.
const absolute = appt.add({ hours: 24 });
console.log(absolute.toString());
// '2024-03-10T10:00:00-04:00[America/New_York]' — 10 AM, because the day was 23h long
The choice is explicit and intentional, not an accident of host configuration. For the full mental model of mutation-free calendar math, see date arithmetic without mutations and its detailed walkthrough of adding months without overflow.
Disambiguating ambiguous and nonexistent local times
When you build a ZonedDateTime from a bare local time, DST transitions create two failure modes: a fall-back hour that exists twice and a spring-forward hour that does not exist at all. The disambiguation option decides what happens.
import { Temporal } from '@js-temporal/polyfill';
// Fall-back: 2024-11-03 01:30 occurs twice in New York.
const local = Temporal.PlainDateTime.from('2024-11-03T01:30:00');
// 'earlier' picks the first (pre-transition) occurrence, EDT -04:00.
const first = local.toZonedDateTime('America/New_York', { disambiguation: 'earlier' });
// 'later' picks the second (post-transition) occurrence, EST -05:00.
const second = local.toZonedDateTime('America/New_York', { disambiguation: 'later' });
console.log(first.equals(second)); // false — one hour apart in real time
// 'compatible' (the default) mirrors legacy Date behavior; 'reject' throws on ambiguity.
Use 'reject' in scheduling pipelines where a silently-shifted time is a data-integrity bug, and choose 'earlier'/'later' deliberately when a policy dictates which occurrence wins.
Production implementation
In production the recurring theme is: keep the zone attached from the moment a time enters the system until it leaves, and choose calendar-unit or clock-unit arithmetic deliberately at each step. A booking service stores each appointment as a full ZonedDateTime string so the customer's wall-clock intent survives a later rule change; a reminder scheduler advances by { days: 1 } so 9am stays 9am across a transition; an SLA monitor advances by { hours: 4 } because it genuinely means four hours of elapsed time. The type does not make the decision for you, but it makes the decision expressible, which is the prerequisite for getting it right.
The other production discipline is reducing to the right type for each job. Compare and sort events by their instant (epochNanoseconds) when you care about ordering on the physical timeline; reduce to a PlainDate in a chosen zone when you care about civil-calendar bucketing like "which day did this happen for the user." Storing the zoned value as the canonical record and deriving these projections on demand keeps one source of truth while serving every downstream question. That pattern — canonical ZonedDateTime, derived projections — is the backbone of a codebase that handles time without drift.
A reusable factory that validates input, supplies an explicit zone, and rejects ambiguity by default keeps drift out of your codebase.
import { Temporal } from '@js-temporal/polyfill';
export interface ZonedInput {
/** ISO local date-time WITHOUT offset, e.g. '2024-11-03T01:30:00'. */
local: string;
/** IANA identifier, e.g. 'Europe/Berlin'. Never a numeric offset. */
timeZone: string;
disambiguation?: 'compatible' | 'earlier' | 'later' | 'reject';
}
export function makeZoned({
local,
timeZone,
disambiguation = 'reject', // fail loud on DST ambiguity in scheduling code
}: ZonedInput): Temporal.ZonedDateTime {
let plain: Temporal.PlainDateTime;
try {
plain = Temporal.PlainDateTime.from(local); // throws on malformed input
} catch {
throw new Error(`Invalid local date-time: "${local}"`);
}
try {
// toZonedDateTime applies the real tzdata rules for this zone + instant.
return plain.toZonedDateTime(timeZone, { disambiguation });
} catch (err) {
// 'reject' surfaces gap/overlap times here instead of silently shifting them.
throw new Error(`Cannot resolve ${local} in ${timeZone}: ${(err as Error).message}`);
}
}
On servers, serverless functions, and edge runtimes, never call Temporal.Now.zonedDateTimeISO() without an argument — it reads the host zone, which is almost always UTC in production and rarely the user's. Pass the zone from the user's profile or a request header. In containers, keep tzdata current so recent DST legislation resolves correctly; a stale image will compute offsets from outdated rules. The arithmetic behind those offsets is covered in timezone offset math explained.
Edge cases
The headline edge cases are the two kinds of daylight-saving transition. A spring-forward gap deletes a wall-clock hour: 2:30am simply does not exist on that date, so anchoring that wall time requires a disambiguation policy to resolve it forward. A fall-back overlap repeats an hour: 1:30am occurs twice, so the same wall time maps to two instants and you must choose which. ZonedDateTime construction takes a disambiguation option ('compatible', 'earlier', 'later', 'reject') so these are explicit decisions rather than silent guesses — the default handles most cases, but scheduling right at a transition should choose on purpose.
Beyond DST, there are zones with offsets that are not whole hours (India at +5:30, Nepal at +5:45), zones that have changed their rules over history, and the very rare political redefinition of a zone. Because ZonedDateTime resolves offsets through the IANA database keyed on the instant, it handles the fractional and historical cases without special code. The one thing to store carefully is the IANA identifier itself rather than a frozen offset, so that a value written today still resolves correctly if the zone's future rules change — the difference between a durable record and one that silently rots.
Spring-forward gap (nonexistent time)
import { Temporal } from '@js-temporal/polyfill';
// 2:30 AM does not exist on 2024-03-10 in New York.
const gap = Temporal.PlainDateTime.from('2024-03-10T02:30:00');
const compatible = gap.toZonedDateTime('America/New_York'); // default 'compatible'
console.log(compatible.toString());
// '2024-03-10T03:30:00-04:00[America/New_York]' — pushed forward into the next valid hour
Fall-back overlap (time exists twice)
Already shown above: the same local string maps to two distinct instants one hour apart. Decide which one your domain means.
Month-end rollover
import { Temporal } from '@js-temporal/polyfill';
const jan31 = Temporal.ZonedDateTime.from('2024-01-31T12:00:00-05:00[America/New_York]');
// Calendar arithmetic constrains to the last valid day rather than overflowing.
console.log(jan31.add({ months: 1 }).toPlainDate().toString()); // '2024-02-29' (leap year)
Re-projecting across zones
import { Temporal } from '@js-temporal/polyfill';
const tokyo = Temporal.ZonedDateTime.from('2024-06-01T09:00:00+09:00[Asia/Tokyo]');
// Same instant, viewed from another zone — the wall clock changes, the moment does not.
console.log(tokyo.withTimeZone('America/New_York').toString());
// '2024-05-31T20:00:00-04:00[America/New_York]'
Gotchas and common pitfalls
- Implicit host zone: calling
Temporal.Now.zonedDateTimeISO()with no argument. Fix: always pass an explicit IANA zone in server and edge code. - Legacy millisecond arithmetic:
date.getTime() + 86400000to "add a day." Fix: use.add({ days: 1 })so DST is respected. - Hardcoded numeric offsets: storing
-05:00instead ofAmerica/New_York. Fix: persist the IANA identifier; offsets change with legislation. - Ignoring disambiguation: trusting the
'compatible'default in scheduling. Fix: use'reject'to catch gap/overlap times, or choose'earlier'/'later'by policy. - Inventing
Temporal.Duration.between(): it does not exist. Fix: usestart.until(end)orend.since(start).
Testing checklist
| Scenario | Input | Expected |
|---|---|---|
| Add a day across spring-forward | 2024-03-09T09:00[NY].add({days:1}) |
2024-03-10T09:00 -04:00 |
| Add 24 hours across spring-forward | 2024-03-09T09:00[NY].add({hours:24}) |
2024-03-10T10:00 -04:00 |
| Nonexistent local time | 2024-03-10T02:30 → NY (compatible) |
03:30 -04:00 |
| Fall-back, earlier vs later differ | 2024-11-03T01:30 earlier vs later |
not .equals() |
| Month-end constrain | 2024-01-31[NY].add({months:1}) |
date 2024-02-29 |
Run your suite under several host zones to flush out implicit-zone assumptions:
# CI matrix — the result must be identical regardless of the host zone.
for tz in UTC America/New_York Asia/Tokyo Pacific/Chatham; do
TZ=$tz npx jest zoneddatetime
done
Frequently Asked Questions
Why does .add({ days: 1 }) give a different result than .add({ hours: 24 })?
Calendar units like days preserve the wall-clock time, so 9 AM stays 9 AM even when the day is 23 or 25 hours long across a DST transition. Time units like hours add absolute elapsed time, so .add({ hours: 24 }) advances the instant by exactly 24 hours and can land on a different local clock time.
When should I use ZonedDateTime instead of Instant?
Use ZonedDateTime when the wall-clock time and timezone matter to your domain: scheduling, business hours, and user-facing timestamps. Use Instant for UTC-only concerns such as logging, idempotency keys, and storage where timezone context is irrelevant.
How do I convert a legacy Date to a ZonedDateTime safely?
Use Temporal.Instant.fromEpochMilliseconds(date.getTime()).toZonedDateTimeISO(zone), supplying the IANA zone you intend to interpret the moment in. Validate the original Date's zone assumptions first so you do not lock in silent drift. A full round-trip walkthrough lives in the related guide on converting between Date and Temporal.Instant.
Is ZonedDateTime ready for production?
Yes, with @js-temporal/polyfill pinned to a fixed version. Native support is progressively shipping in browsers and Node.js, and edge runtimes may need explicit timezone-database bundling.
What is a Temporal.ZonedDateTime and when should I use it?
It is a date-time that carries three facts at once: the exact instant, the wall-clock reading, and the named IANA time zone that links them. Use it whenever a time is meaningful in a particular place — appointments, reminders, store hours, anything a user reads on a local clock — because it converts between zones exactly, does daylight-saving-aware arithmetic, and serializes to one self-describing string. Reach for the simpler PlainDate or Instant only when the value genuinely has no zone (a birthday) or is purely an instant (a log timestamp).
Why does adding one day differ from adding 24 hours on a ZonedDateTime?
They diverge on daylight-saving days. Adding { days: 1 } keeps the same wall-clock time on the next calendar day even when that day is 23 or 25 hours long, which is what human scheduling means by 'tomorrow at 9am'. Adding { hours: 24 } moves exactly 24 hours of physical time, landing an hour off the wall clock on a transition day. Both are valid but answer different questions, so choose calendar units for wall-clock scheduling and clock units for elapsed-time reasoning.
How do I store a ZonedDateTime without losing information?
Serialize it with toString(), which produces a self-describing string like 2024-03-15T09:00:00-04:00[America/New_York], and store that in a text column. It keeps the instant, the offset, and the IANA zone, so you can reconstruct the exact wall-clock time even after a future rule change. Storing only a UTC timestamp loses the zone, and storing only an offset loses the ability to recompute future offsets — keep the bracketed IANA identifier.
Related
- Modern Date Logic with the Temporal API — the parent overview.
- Compare ZonedDateTime across different timezones — instant vs. wall-clock comparison.
- Recurring event scheduling across DST — applying day arithmetic to repeating events.
- Convert a Date to a Temporal.Instant and back — interop with legacy
Date. - Date arithmetic without mutations — the immutable calculation model.