Working With Dates and Timezones in JavaScript Without Losing a Weekend
JavaScript timezones are the single largest source of "works on my machine" bugs in a global web app. The reason is that the language shipped a Date object in 1995 modelled on java.util.Date, with mutable state, a single hidden timezone, and no concept of named zones like Europe/Berlin — and it has been the only built-in choice for thirty years. In 2026, that finally changes. The Temporal API reached TC39 Stage 4 in March 2026 and is now part of ECMAScript 2026, with native support shipping in Chrome 144, Firefox 139, and Edge 144. This guide covers what changed, what to use today, and the patterns that stop date bugs before they ship.
Key takeaways
- The legacy
Dateobject is mutable, has no real timezone awareness, and parses inconsistently across engines — treat it as a serialisation primitive, not a working type. - The Temporal API is now Stage 4 and part of ES2026. Chrome 144 and Firefox 139 ship it natively, Safari support is in Technology Preview. For Node.js and older browsers, use the
@js-temporal/polyfill(~100KB) or wait for native support. - date-fns v4 added first-class timezone support via
@date-fns/tzin 2024. It is the practical default for new TypeScript projects in 2026. - Luxon 3.7 is the right choice for timezone-heavy apps today and was designed as a stepping stone to Temporal.
- Moment.js has been a legacy maintenance-mode project since September 2020 — no new features, no bundle-size fixes, no v3. Migrate.
- The rule that prevents 90% of date bugs: store UTC ISO 8601 strings on the server, format on the client with the user's IANA timezone.
Why dates are notoriously hard
A date in software encodes four things at once: a moment on the absolute timeline (a UTC instant), a calendar interpretation (year, month, day in some calendar system), a wall-clock time, and the timezone rules connecting the last three to the first. Most date bugs are caused by conflating two of those four.
The world makes this worse on its own. There are more than 600 IANA timezones, each with its own history of UTC offset and daylight-saving changes. Brazil scrapped DST in 2019. Paraguay stopped changing clocks in 2024, finalised in tzdb 2025a. Chile's Aysén Region split into its own zone, America/Coyhaique, in March 2025. Kazakhstan unified on UTC+5. The IANA Time Zone Database releases multiple updates per year — 2026a shipped in April 2026 — and any system that hard-codes offsets instead of reading from this database drifts out of step within months.
Add the DST edge cases — the hour that doesn't exist in spring, the hour that happens twice in autumn, the half-hour and quarter-hour offsets in India and Nepal — and the classic JavaScript Date was designed before any of this was a daily concern for web apps. It shows.
What the legacy Date object gets wrong
Date represents a single moment in time as the number of milliseconds since the Unix epoch — that part is fine. Everything else is rough.
It is mutable. date.setHours(0) modifies the original. Pass a Date into a function and you have no guarantee it comes back unchanged. This is the single biggest source of "the calendar showed the wrong day after I clicked next month" bugs in React components.
It has no real timezone. Internally it stores UTC. Externally it pretends to live in the browser's local zone — but only the offset, not the named zone. There is no API on a Date to ask "what zone are you in" beyond reading Intl.DateTimeFormat().resolvedOptions().timeZone separately.
Parsing is inconsistent. The spec only guarantees ISO 8601 strings parse correctly. Everything else is engine-defined. new Date('2026-01-15') is treated as UTC midnight by the spec, but new Date('2026-01-15T00:00:00') (no zone suffix) is treated as local. The single character of difference changes the displayed date by up to a day depending on where the user lives.
// User in Los Angeles (UTC-8)
new Date('2026-01-15').toString();
// → "Wed Jan 14 2026 16:00:00 GMT-0800 (PST)" — yesterday
new Date('2026-01-15T00:00:00').toString();
// → "Thu Jan 15 2026 00:00:00 GMT-0800 (PST)" — today
new Date('Jan 15 2026').toString();
// → engine-defined, do not rely on this
Month indices are zero-based, day-of-month is one-based. new Date(2026, 0, 15) is January 15, not February 15. There is no good reason for this in 2026.
Arithmetic is offset-naive. Adding 24 hours to a Date adds 86,400,000 milliseconds. Around DST transitions that crosses a wall-clock boundary in the wrong place — "next week's meeting" drifts by an hour twice a year. As a serialisation primitive Date is fine; as a working type, reach for something else.
The Temporal API — what's new in ES2026
Temporal is a new namespace in the JavaScript standard library that replaces Date with a set of immutable, timezone-aware types. The proposal reached TC39 Stage 4 in March 2026 and is now part of the ECMAScript 2026 specification. It shipped natively in Firefox 139 (May 2025), Chrome 144 (January 2026), and Edge 144. Safari support is in Technology Preview, with stable support expected later in 2026.
The core types map cleanly onto the four things a date can mean:
| Temporal type | Represents | Example |
|---|---|---|
Temporal.Instant | A UTC moment, no calendar | 2026-05-22T08:00:00Z |
Temporal.ZonedDateTime | An instant in a named timezone | 2026-05-22T10:00:00+02:00[Europe/Berlin] |
Temporal.PlainDateTime | A wall-clock date and time, no zone | 2026-05-22T10:00:00 |
Temporal.PlainDate | A calendar date, no time, no zone | 2026-05-22 |
Temporal.PlainTime | A clock time, no date, no zone | 10:00:00 |
Temporal.PlainYearMonth | Useful for billing cycles, credit cards | 2026-05 |
Temporal.Duration | An amount of time | P1Y2M3DT4H5M6S |
Everything is immutable. Every operation returns a new object. Arithmetic respects the calendar — adding one month to January 31 in Temporal lands on February 28 or 29, not on March 3.
// Format the current moment in a specific user's timezone
const now = Temporal.Now.zonedDateTimeISO('Europe/Berlin');
now.toLocaleString('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
});
// → "Freitag, 22. Mai 2026 um 10:00"
// Add a day across a DST boundary, correctly
const beforeDst = Temporal.ZonedDateTime.from(
'2026-03-28T10:00:00[Europe/London]'
);
const afterDst = beforeDst.add({ days: 1 });
// afterDst is 2026-03-29T10:00:00+01:00[Europe/London]
// — still 10am wall-clock, even though only 23 hours have passed
The string format Temporal uses — 2026-05-22T10:00:00+02:00[Europe/Berlin] — comes from RFC 9557, ratified in October 2024. RFC 9557 was the missing piece that let browser engines finally ship Temporal; before it existed, there was no agreed wire format for "instant plus zone." A proposal that reached Stage 3 in 2021 took until 2025 to ship because of it.
When to use Temporal today. In greenfield code targeting Chrome 144+, Firefox 139+, and the latest Safari, use Temporal directly. For Node.js and broader browser support, the @js-temporal/polyfill is official — ~100KB gzipped, fine for internal tools, heavy for marketing sites. TypeScript 6.0 Beta added Temporal types in February 2026.
UTC vs local time — when to store and when to display
The pattern that prevents most of the bugs in this article fits in three rules.
Store in UTC, in ISO 8601 format. 2026-05-22T08:00:00Z on the wire and in the database. No offsets, no ambiguity, no engine-specific parsing.
Pass UTC through the stack. Server returns UTC. Frontend stores UTC. Caches store UTC. State managers store UTC. The string never gets reinterpreted in transit.
Render in the user's timezone, at the latest possible moment. Only when the date is about to hit the DOM does the code ask "what timezone is the user in, and what locale do they expect?" — and converts. This puts a single boundary between the canonical representation and the displayed one, and it puts that boundary where the user's preferences actually live.
The only common exception is calendar dates that aren't moments — a birthday, a holiday, a milestone date. Those are Temporal.PlainDate (or a plain ISO date like 2026-05-22). Treating "May 22" as a UTC instant is the bug that makes a Seoul user's birthday display as May 21 to a Honolulu friend.
Picking a date library: comparison table
Until Safari ships Temporal and Node.js adds native support, most production codebases will keep a library in the mix. Here's how the live options compare in May 2026.
| Library | Latest version | Bundle (min+gz) | Mutability | IANA TZ | Style | Maintenance status |
|---|---|---|---|---|---|---|
| Temporal (native) | ES2026 / TC39 Stage 4 | 0 KB where supported (~100 KB polyfill) | Immutable | First-class | Standard library | Active, ships in Chrome 144, Firefox 139, Edge 144 |
| date-fns | v4.x | ~13 KB tree-shaken typical | Immutable | First-class via @date-fns/tz (v4+) | Functional, tree-shakeable | Active, ~40M weekly downloads |
| Luxon | 3.7.2 | ~23 KB | Immutable | First-class (Intl-based) | OO, chainable, Temporal-shaped | Active, designed as Temporal stepping stone |
| Day.js | 1.11.x | ~2 KB core + plugins | Immutable | Plugin (utc, timezone) | OO, Moment-compatible API | Active, ~25M weekly downloads |
| Moment.js | 2.x | ~72 KB (300 KB with tz data) | Mutable | Plugin (moment-timezone) | OO, legacy | Maintenance mode since Sep 2020 — no new features, no v3 |
A quick read of the rows above:
- Modern browsers only: native Temporal.
- Full browser and Node support: date-fns v4 +
@date-fns/tz, or Luxon if the team prefers a chainable API. - Migrating off Moment.js: Day.js with the
utcandtimezoneplugins — closest API surface, smallest bundle. - Already on Moment.js: it still works, but plan the migration. Moment won't get a v3 and won't shrink. Chrome DevTools has flagged it as a bundle-size warning since 2021.
"Format a date in the user's timezone" — the same task in every library
The most common date task in a web app is also the one most likely to bite: take a UTC timestamp from the server, render it in the user's local zone, in the user's locale. Here's that task in each library, against the same fixture — a UTC ISO string of 2026-05-22T08:00:00Z, rendered for a user in America/New_York, locale en-US.
Native Temporal (Chrome 144+, Firefox 139+)
const utcIso = '2026-05-22T08:00:00Z';
const userZone = 'America/New_York';
const zoned = Temporal.Instant.from(utcIso).toZonedDateTimeISO(userZone);
zoned.toLocaleString('en-US', {
dateStyle: 'full',
timeStyle: 'short',
});
// → "Friday, May 22, 2026 at 4:00 AM"
date-fns v4 with @date-fns/tz
import { format } from 'date-fns';
import { tz } from '@date-fns/tz';
const utcIso = '2026-05-22T08:00:00Z';
format(new Date(utcIso), "EEEE, MMMM d, yyyy 'at' h:mm a", {
in: tz('America/New_York'),
});
// → "Friday, May 22, 2026 at 4:00 AM"
Luxon
import { DateTime } from 'luxon';
const utcIso = '2026-05-22T08:00:00Z';
DateTime.fromISO(utcIso, { zone: 'utc' })
.setZone('America/New_York')
.setLocale('en-US')
.toLocaleString(DateTime.DATETIME_FULL);
// → "May 22, 2026 at 4:00 AM EDT"
Day.js with utc and timezone plugins
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import advancedFormat from 'dayjs/plugin/advancedFormat';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);
const utcIso = '2026-05-22T08:00:00Z';
dayjs
.utc(utcIso)
.tz('America/New_York')
.format('dddd, MMMM D, YYYY [at] h:mm A');
// → "Friday, May 22, 2026 at 4:00 AM"
Moment.js (for reference — migrate off this)
import moment from 'moment-timezone';
const utcIso = '2026-05-22T08:00:00Z';
moment
.utc(utcIso)
.tz('America/New_York')
.format('dddd, MMMM D, YYYY [at] h:mm A');
// → "Friday, May 22, 2026 at 4:00 AM"
The shape of the code is the same across all five — parse as UTC, convert to the target zone, format with a locale. The interesting differences are bundle cost, mutability, and how each library sources timezone data. Day.js and Moment ship moment-timezone-style data files (heavy). Luxon, Temporal, and date-fns v4 use the browser's Intl API, piggy-backing on the engine's IANA database and staying current without bundle updates.
The bug patterns that ship every quarter
Six of the most common date-handling defects, and what fixes each:
1. Date-only events going off-by-one for west-of-UTC users. A "May 22 birthday" is stored as 2026-05-22T00:00:00Z. A user in Hawaii sees it as "May 21." Fix: store calendar dates as plain date strings (2026-05-22), not as instants. With Temporal, this is Temporal.PlainDate.
2. Server uses Node's TZ environment variable; container in Frankfurt, deployed to a US region. Logs and timestamps shift by 6 hours overnight when the container restarts in a new region. Fix: set TZ=UTC explicitly on every server process. Never trust the host's default zone.
3. new Date('2026-05-22') parsed as UTC midnight, rendered with toLocaleDateString(). Looks correct in Berlin tests, shifts one day for any user west of London. Fix: never parse date-only strings with new Date(). Use Temporal.PlainDate.from() or the equivalent in your library.
4. DST transition silently moves a recurring meeting. A weekly standup at 9:00 New York time, stored as 2026-03-08T13:00:00Z, lands at 8:00 New York time on March 9 — the day after spring-forward — because the offset changed from UTC-5 to UTC-4. Fix: store the recurring event as a wall-clock time in a named zone (Temporal.ZonedDateTime or equivalent), not as a UTC instant. Materialise instants for each occurrence.
5. Cached UTC timestamps in a service-worker shell, displayed without re-conversion when the user crosses a timezone. A traveller flying London → Tokyo sees timestamps from their flight rendered against London time after they land. Fix: never cache the formatted string; cache the UTC instant and re-format on every render.
6. Filtering "today's records" in SQL with WHERE created_at::date = CURRENT_DATE. Server uses UTC, user is in Sydney. Sydney's "today" is the server's "yesterday afternoon" for half the day. Fix: pass the user's zone into the query — WHERE (created_at AT TIME ZONE 'Australia/Sydney')::date = :userToday.
Patterns 4 and 6 are the ones that get past most code reviews — they look right in tests written in the same zone as production.
Practical patterns that prevent the bugs above
Standardise on ISO 8601 with Z. Every timestamp on the wire ends in Z. Anything else is a bug ticket waiting to be filed. APIs reject offsets, return UTC, and let the client convert.
Carry the user's IANA zone as a profile field. Don't infer it on every request. Ask once (or detect on first login via Intl.DateTimeFormat().resolvedOptions().timeZone), store it, send it with the user's session. Then every render has the zone available without guessing.
Use Intl.DateTimeFormat directly when you don't need a library. It is supported everywhere and is what most libraries delegate to internally. For "show a formatted date" with no arithmetic, new Intl.DateTimeFormat('en-US', { timeZone, dateStyle: 'full', timeStyle: 'short' }).format(date) is usually enough.
Never round-trip dates through .toLocaleString() and back. That string is for display only — parsing it later loses information in subtle ways depending on locale.
Add fixtures with adversarial zones to your test suite. Pacific/Kiritimati (UTC+14), Pacific/Niue (UTC-11), Asia/Kolkata (UTC+5:30), Asia/Kathmandu (UTC+5:45). Half-hour and quarter-hour zones catch off-by-30-minute bugs that UTC-only test runs never surface.
Pin the clock in tests. Use the library's clock-injection mechanism and pin a date. Tests that pass today and fail in 90 days are the worst kind of flake.
FAQ
Is the Temporal API safe to use in production in 2026?
Yes, in environments you control. Native support ships in Firefox 139+, Chrome 144+, and Edge 144+. Safari stable support is expected later in 2026. For Node.js or older browsers, the @js-temporal/polyfill is official — ~100KB gzipped, fine for internal tools, heavy for marketing sites. If the polyfill is too much weight, stay on date-fns v4 or Luxon and plan the Temporal migration for late 2026 or 2027.
Should I migrate off Moment.js?
Yes, but on your schedule. Moment has been in maintenance mode since September 2020 — no new features, no immutability, no bundle-size fixes, no v3. It still works, and the maintainers will still review security patches, but new code shouldn't pull it in. For the smallest API delta during migration, Day.js with the utc and timezone plugins is closest. For better long-term ergonomics, Luxon or date-fns v4.
What's the difference between Date, Temporal.Instant, and Temporal.ZonedDateTime?
Date is a UTC millisecond timestamp that pretends to be in the local zone when you ask it to print. Temporal.Instant is a UTC nanosecond timestamp with no calendar or zone — pure absolute time. Temporal.ZonedDateTime wraps an Instant with a named IANA timezone so arithmetic, formatting, and "what's the wall-clock time" all work correctly across DST and offset changes.
Why does new Date('2026-05-22') print as May 21 for some users?
The ISO 8601 spec says a date-only string is parsed as UTC midnight. For any user west of UTC, that UTC midnight falls on the previous day in their local timezone. The two fixes are: (1) parse with an explicit local time — new Date('2026-05-22T00:00:00') — or (2) treat calendar dates as plain dates, not instants. The second is what Temporal.PlainDate exists for.
How do I get the user's timezone in the browser?
Intl.DateTimeFormat().resolvedOptions().timeZone returns the user's IANA zone — Europe/Berlin, America/Los_Angeles, Asia/Tokyo. It's supported in every modern browser and is the right primitive for initial detection. Persist it on the user record so subsequent requests don't depend on browser detection.
Do I still need a date library if Temporal is shipping?
For environments that target only Chrome 144+, Firefox 139+, and current Safari, no. For everything else, yes — until Safari stable support lands and Node.js ships native Temporal, a library bridges the gap. date-fns v4 and Luxon both have explicit migration paths to Temporal and are the safest bets for code you want to outlive the transition.
What changed in the IANA timezone database recently?
tzdb 2025a (January 2025) made Paraguay permanently UTC-3. tzdb 2025b (March 2025) added America/Coyhaique for Chile's Aysén Region. tzdb 2026a (April 2026) is the most recent release. Intl-based libraries (Luxon, date-fns v4, native Temporal) pick up engine updates automatically; moment-timezone and dayjs-timezone-with-data need bundle updates to stay current.
Where Crosscheck fits
Date and timezone bugs are some of the hardest to diagnose remotely — the user's browser zone, the server's TZ env var, the cache layer, and the IANA database version are all involved, and none of them are usually in the screenshot. Crosscheck is a free Chrome extension that captures the screenshot, screen recording, console logs, network requests, and browser metadata — including the user's resolved timezone and locale — into a single bug report sent straight to Jira, Linear, ClickUp, GitHub, or Slack. When a "wrong date" ticket comes in, the team already has the zone, the request payload, and the rendered output in one place.
For more on debugging the layers around date handling, the Crosscheck team has also written about JavaScript debugging tips for developers, Chrome DevTools performance auditing, and the perfect bug report template.



