Time Zones in JavaScript: The Developer's Guide
JavaScript's built-in Date object handles time zones poorly. Here's how to use the Intl API, Luxon, and the IANA database to handle time zones correctly in production apps.
JavaScript's Date object is always in UTC internally — but it displays in local time by default. To work with a specific time zone (not the user's local zone), use the Intl.DateTimeFormat API or a library like Luxon. Never use .getHours() or .getTimezoneOffset() for zone-aware logic — they always reflect the local system zone.
Open Timezone Converter →Time zone handling is one of the most common sources of bugs in production JavaScript applications. An event scheduled for '3 PM' renders correctly on one developer's machine and an hour off on another's. A cron job fires at the wrong time after a server moves to a different region. A timestamp stored at midnight drifts to the previous day when converted for a user in UTC-5. These bugs all share a root cause: JavaScript's Date object does not handle time zones in an intuitive way.
What JavaScript's Date Object Actually Does
A JavaScript Date object stores a single integer: milliseconds since the Unix epoch (January 1, 1970 00:00:00 UTC). That integer is always UTC. There is no time zone stored in the Date object itself.
The confusion arises from methods like .toString(), .toLocaleString(), and .getHours(). These methods implicitly convert to the system's local time zone — whatever time zone the browser or Node.js process is running in. This is why the same Date object can print different things on different machines.
| Method | Returns | Time zone aware? |
|---|---|---|
| date.getTime() | Milliseconds since epoch | No — UTC always |
| date.toISOString() | ISO 8601 in UTC (Z suffix) | No — UTC always |
| date.getHours() | Hour in local system time | Yes — system zone only |
| date.toString() | String in local system time | Yes — system zone only |
| date.toLocaleString() | Formatted local string | Yes — configurable |
| Intl.DateTimeFormat | Formatted string in any zone | Yes — full IANA support |
The Right Way: Intl.DateTimeFormat
The Intl.DateTimeFormat API is built into every modern JavaScript runtime and supports full IANA time zone identifiers. It is the correct way to format a Date in a specific zone without a third-party library.
- new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit' }).format(date) — formats in Eastern time
- new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', dateStyle: 'full', timeStyle: 'long' }).format(date) — full London date string
- Intl.DateTimeFormat().resolvedOptions().timeZone — get the user's IANA zone identifier
The key: always pass a timeZone option using an IANA identifier. Never pass abbreviations like 'EST' or 'PST' — these are not valid IANA identifiers and browser support for them is inconsistent.
For Complex Logic: Use Luxon
For anything more than formatting — calculating durations, adding days, comparing times across zones, or handling DST edge cases — use Luxon. Luxon wraps JavaScript's Intl API and IANA tzdb to provide a clean, immutable DateTime API.
- DateTime.now().setZone('America/New_York') — current time in NY, DST-correct
- DateTime.fromISO('2026-03-08T02:30', { zone: 'America/New_York' }) — DST spring-forward: this time does not exist, Luxon handles it gracefully
- dt.diff(other, ['hours', 'minutes']) — duration between two moments in any zone
- dt.toISO() — always outputs UTC ISO 8601 regardless of the display zone
Luxon's key advantage over Moment.js (which is in maintenance mode) and date-fns (which has limited zone support) is that it uses the browser's native Intl.DateTimeFormat under the hood — no bundled timezone database needed. The IANA tzdb comes from the platform.
The Temporal API: The Future Standard
The TC39 Temporal proposal is a new built-in JavaScript API designed to replace Date entirely. As of mid-2026, Temporal is Stage 3 and available in Firefox Nightly and Chrome Canary behind a flag. It ships with first-class time zone support, distinguishing between a ZonedDateTime (a moment tied to a specific IANA zone) and a PlainDateTime (a date and time with no zone attached).
Temporal.ZonedDateTime.from('2026-06-15T14:30[America/New_York]') creates an unambiguous, DST-aware moment. Unlike Date, it never silently converts to local time. For production code today, use Luxon — but watch Temporal closely.
Common Time Zone Bugs and How to Avoid Them
| Bug | Cause | Fix |
|---|---|---|
| Date is off by one day | new Date("2026-06-15") parses as UTC midnight, .getDate() reads local date | Parse with explicit time: new Date("2026-06-15T00:00:00") or use Luxon DateTime.fromISO |
| Hour is wrong in production | .getHours() uses server local zone, not user zone | Use Intl.DateTimeFormat with explicit timeZone, or Luxon setZone() |
| Time wrong after DST change | Adding 86400 seconds assumes 24-hour day | Use Luxon .plus({ days: 1 }) which is DST-aware |
| Times match in dev but not prod | Dev machine is in local zone, server is UTC | Always store and compare in UTC; convert to display zone only at render time |
| cron fires at wrong hour | System timezone set to local, not UTC | Set TZ=UTC on servers; use IANA zones in cron expressions |
Best Practices Summary
- Store timestamps in UTC. Always. Use date.toISOString() or date.getTime() to serialize.
- Use IANA identifiers ('America/New_York'), never abbreviations ('EST', 'PST').
- Get the user's zone with Intl.DateTimeFormat().resolvedOptions().timeZone — not navigator.language.
- For formatting only: use Intl.DateTimeFormat with a timeZone option.
- For date math (adding days, finding the next Monday, etc.): use Luxon.
- Set TZ=UTC on all servers and CI environments to prevent surprises.
- Test DST edge cases: the hour that does not exist (spring forward) and the hour that repeats (fall back).
Frequently asked questions
- Should I use moment.js for time zone handling?
- Moment.js is in maintenance mode — no new features, only security fixes. Use Luxon (the spiritual successor by the same author) or the native Intl API instead. For new projects, avoid moment.js entirely.
- What is the difference between new Date() and Date.now()?
- Date.now() returns a number — milliseconds since the Unix epoch in UTC. new Date() returns a Date object wrapping the same moment. For most math, Date.now() is simpler and avoids object allocation. Both are always UTC internally.
- How do I convert a date to a user's local time zone in Node.js?
- Use Intl.DateTimeFormat with the user's IANA time zone identifier, which you get from the browser via Intl.DateTimeFormat().resolvedOptions().timeZone and pass to your API. Never rely on the server's local time zone for user-facing output.
- Why does new Date("2026-06-15") give a different day than expected?
- Date-only ISO strings (without a time component) are parsed as UTC midnight. When you call .getDate() or .toLocaleDateString(), JavaScript converts to local time — which can be the previous day in UTC-N zones. Fix: always include a time component, e.g. new Date("2026-06-15T12:00:00").
We build practical, free time and date tools at epochcalc.com — every calculation runs in your browser using IANA tzdb via Luxon, so DST and zone math are correct by construction.