Why JavaScript's Date has no timezone
A JavaScript Date is a UTC millisecond count. There is no timezone stored inside the object. When you call .toString() or .toLocaleString() without options, JavaScript falls back to the runtime's local timezone — read from the operating system. The same code produces different output on a server in New York vs a laptop in Tokyo, even though the underlying timestamp is identical. This is the single fact that explains every Date timezone surprise: the timezone is applied at format time, never at storage time.
The stored value is epoch milliseconds
Date.now() and date.getTime() return a 13-digit millisecond count from the UTC epoch. That value is timezone-neutral.
UTC output with toISOString
date.toISOString() formats the same instant as an ISO 8601 UTC string with a Z suffix.
Local output happens at format time
toString(), toLocaleString(), and Intl.DateTimeFormat apply the runtime or requested timezone only when building the display string.
Epoch to UTC in JavaScript
For JavaScript, epoch to UTC is a formatting step, not a timezone conversion of the stored value. Multiply 10-digit Unix seconds by 1000 before constructing a Date; pass 13-digit milliseconds directly. Then call toISOString() for UTC, or Intl.DateTimeFormat with timeZone: 'UTC' when you need a localized UTC display.
- Unix seconds to UTC: new Date(1700000000 * 1000).toISOString()
- Unix milliseconds to UTC: new Date(1700000000000).toISOString()
- Current UTC ISO string: new Date().toISOString()
- UTC display with Intl: new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'long' }).format(date)
Format a date in any timezone with Intl.DateTimeFormat
Intl.DateTimeFormat is the built-in, zero-dependency API for locale- and timezone-aware formatting. The trick is to pass the timeZone option explicitly — once you do, the runtime uses the IANA database to pick the correct offset for the date you're formatting, automatically handling DST transitions. The API has been universally supported in browsers and Node.js since 2017 (Node 13+ ships full ICU data). Cache the formatter instance when you reuse it; constructing one is the slow part.
- new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', dateStyle: 'full', timeStyle: 'long', timeZoneName: 'short' }).format(date)
- date.toLocaleString('en-US', { timeZone: 'America/New_York' }) // newcomer's one-liner
- date.toLocaleString('en-GB', { timeZone: 'Europe/London', hour12: false })
- date.toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' })
- new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', hour12: false }).format(date) // force UTC display
- Performance tip: const fmt = new Intl.DateTimeFormat(…); rows.forEach(r => fmt.format(r.date)) -- reuse the instance
Display in UTC
Use timeZone: 'UTC' when server output, logs, or admin tables must be stable across machines.
Display in a user timezone
Use an IANA name such as America/New_York or Asia/Tokyo. Avoid three-letter abbreviations such as EST and PST in code.
Extract individual parts with formatToParts
Use formatToParts() to get the date as a list of {type, value} objects instead of one formatted string. This is the right approach when you need to construct a custom string — punctuation, ordering, and scripts differ by locale, so splitting a localized string by '/' or '-' breaks in subtle ways. formatToParts hands you the parts in the order the locale uses them; you assemble the final string yourself.
- const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date)
- parts.find(p => p.type === 'year').value // '2026'
- parts.find(p => p.type === 'month').value // '06'
- Object.fromEntries(parts.map(p => [p.type, p.value])) // { year, month, day, hour, minute, second }
- Combine with timeZoneName: 'longOffset' to extract a UTC offset like 'GMT-05:00' for a given date in that zone
Get the user's timezone in JavaScript
To find out which timezone the user's browser is set to, call Intl.DateTimeFormat().resolvedOptions().timeZone. It returns an IANA name such as America/Los_Angeles or Europe/Berlin — the same form you pass back into Intl.DateTimeFormat for display. Store this string (not a numeric offset) when you need to reconstruct the user's local time later: numeric offsets drift by an hour every DST transition, IANA names don't. The detected timezone reflects the operating-system clock setting; users can override it in browser-level settings.
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone // 'America/Los_Angeles'
- tz can be passed back into any new Intl.DateTimeFormat(...) call
- Returns 'UTC' on systems that don't expose a local zone (some Docker containers, some CI workers)
- For server code, never use this — the server's tz is not the user's. Pass the user's IANA name from the client in your API requests
- Use this site's /timezone tool to inspect cross-zone conversions for a specific instant
Converting wall-clock time to UTC — the hard direction
Going from a wall-clock time in a given timezone to UTC is the genuinely hard direction. Formatting an existing instant into America/New_York is one line of Intl.DateTimeFormat; constructing the Unix timestamp represented by '2026-03-08 02:30 in America/New_York' is not, because that local time may not exist (DST spring-forward gap) or may be ambiguous (DST fall-back overlap). The classic Date constructor offers no built-in way to do this. Three practical options exist.
- 1. Use Temporal.ZonedDateTime — the right answer if you can use Temporal (Chrome 144+, Firefox 139+, Node 26+)
- 2. Use date-fns-tz toDate() or Luxon DateTime.fromObject({ zone }) — universally supported, ~20-70 KB bundle
- 3. Compute the destination zone's offset for the target date with Intl.DateTimeFormat (timeZoneName: 'longOffset'), then subtract from the wall-clock — this site's zonedToEpochMs() uses one-iteration offset correction
- Decide explicitly how to resolve DST gaps and overlaps — your code must choose 'earlier' or 'later' for the ambiguous fall-back hour
- Never use manual UTC offset arithmetic — DST transitions happen at different local times in different years, and have moved (and will move again) by legislation
Choosing the right timezone identifier
Use IANA timezone names such as America/New_York, Europe/London, Asia/Tokyo, or UTC. Avoid fixed offsets like UTC-5 for user-facing time because offsets change with daylight saving time — America/New_York is UTC-5 in January and UTC-4 in July. The IANA name carries the historical and future DST rules, so the platform applies the correct offset for the specific date you're formatting. The authoritative list is the IANA Time Zone Database, updated by tz maintainers when governments change time zone laws.
- Good: America/Los_Angeles — includes historical and future DST rules
- Good: Europe/Berlin — handles Central European Summer Time automatically
- Good: Asia/Shanghai — stable UTC+8 display, no DST
- Good: UTC — for logs, API payloads, database storage, and cross-region event comparison
- Avoid: numeric offsets like 'GMT-5' or '+09:00' for stored values — they decay at every DST transition
- Avoid: deprecated three-letter abbreviations like 'EST' or 'PST' — ambiguous between standard and daylight, and they don't include rules
DST gap, DST overlap, and the Olson database
Two daylight-saving transitions a year break the assumption that 'wall-clock time + timezone = a unique moment'. The spring-forward gap is the wall-clock hour that does not exist; the fall-back overlap is the wall-clock hour that happens twice. Both are encoded in the IANA timezone database (often still called the Olson database) — the authoritative source of timezone rules used by every modern OS, browser, and language runtime. For 2026 in most US zones, the spring transition was 2026-03-08 02:00 (the 02:00-02:59 hour was skipped); the fall transition is 2026-11-01 02:00 (the 01:00-01:59 hour will happen twice).
- DST gap: 02:00-02:59 local time does not exist on the spring-forward day
- DST overlap: 01:00-01:59 local time occurs twice on the fall-back day
- Unix timestamps themselves never skip or repeat — only the wall-clock interpretation does
- Most production code chooses the post-transition offset for gap times and the second occurrence for overlap times
- Test your conversion code against the actual DST transition dates for your target year and zone
Intl.DateTimeFormat vs Temporal vs date-fns-tz vs Luxon
The right tool depends on what you're doing. For display-only formatting in any timezone, the built-in Intl.DateTimeFormat is enough and ships with the runtime. For arithmetic across timezones — adding days that may span DST, comparing zoned instants, constructing instants from local wall-clock times — Temporal is the modern answer. Library users on date-fns already have a tz extension; Luxon is an excellent zero-dependency DateTime ergonomics layer; moment.js is in maintenance mode and the project itself recommends moving off it for new code.
- Intl.DateTimeFormat — 0 KB, built-in, DST via IANA, universal browser support since 2017 — use for display-only formatting
- Temporal — Stage 4 (March 2026), ~50 KB polyfill, native in Chrome 144 / Firefox 139 / Node.js 26 by default — use for new code that does arithmetic
- date-fns-tz — ~20 KB, DST via IANA, universal — use if you already use date-fns
- Luxon — ~70 KB, full DateTime ergonomics, DST via IANA, universal — use when you want a more complete API than Intl provides
- moment.js — retired (maintenance mode) — do not use for new code; migrate existing code
Common mistakes (don't do these)
Most JavaScript timezone bugs come from a small set of anti-patterns. Reading them once is cheaper than debugging them in production. The pattern is always the same: the developer assumes a fixed offset and gets bitten when DST shifts, when the user moves zones, when the server tz differs from the user tz, or when a library that hides those distinctions is dropped in. Treat timezone as a first-class field, not an afterthought.
- Manual UTC offset arithmetic — new Date(ms - 5 * 60 * 60 * 1000) assumes the destination zone is always UTC-5. It breaks every spring and fall.
- Storing numeric offsets instead of IANA names — a stored '-05:00' becomes wrong the moment DST changes. Store 'America/New_York'.
- Assuming the server timezone — server-side new Date().toLocaleString() uses the container's tz (often UTC, sometimes US/Pacific). Always pass an explicit timeZone option.
- Using moment.js for new code — the project is in maintenance mode and recommends migration. Use Intl.DateTimeFormat + Temporal.
- Storing both a UTC instant and a local string — they diverge on every DST transition. Store one source of truth (UTC instant + IANA name) and derive the rest.
- Comparing strings instead of instants — '2026-06-20T10:00 EST' and '2026-06-20T10:00 PST' aren't equal; sort by Unix timestamp, not by wall-clock string.
FAQ
- Is a JavaScript timestamp always UTC?
- Yes. Date.now() and Date#getTime() return epoch milliseconds counted from 1970-01-01 00:00:00 UTC. The timezone only appears when you format the Date as a string.
- How do I format epoch time as UTC in JavaScript?
- If you have Unix seconds, use new Date(seconds * 1000).toISOString(). If you have milliseconds, use new Date(ms).toISOString(). The Z suffix means UTC.
- Can JavaScript format a date in another timezone without a library?
- Yes. Use Intl.DateTimeFormat or Date.prototype.toLocaleString with a timeZone option set to an IANA name such as America/New_York or Asia/Tokyo. The browser and Node.js apply the correct UTC offset and daylight-saving rule for that date.
- Does Intl.DateTimeFormat handle daylight saving time?
- Yes. When you pass an IANA timezone identifier, the runtime applies the correct offset for that timezone and date — including the DST gap and overlap. Numeric offsets like UTC-5 do not, because they don't know when DST starts and ends.
- How do I get the user's timezone in JavaScript?
- Call Intl.DateTimeFormat().resolvedOptions().timeZone — it returns an IANA name like America/New_York that reflects the browser's system setting. Store that name (not a numeric offset) when you need to reconstruct the user's local time later.
- Should I store timezone offsets or timezone names?
- Store UTC for event instants — Unix timestamps or ISO 8601 with Z. If you also need the user's local context, store an IANA timezone name. A numeric offset like -05:00 loses information at every DST transition.
- Does Intl.DateTimeFormat work in Node.js?
- Yes, since Node 13. Full ICU data (every locale + every timezone) is bundled in the official Node.js binaries from Node 13 onward; older versions ship with small-ICU and only support en-US.
- What is the DST gap (spring-forward)?
- The DST gap is the wall-clock hour that is skipped when daylight saving time begins. In most US zones, when the clock would tick from 02:00 to 02:59 it instead jumps to 03:00. Local times in that window do not exist on the transition day. Unix timestamps themselves never skip; only the wall-clock interpretation does.
- What is the DST overlap (fall-back)?
- The DST overlap is the wall-clock hour that occurs twice when daylight saving time ends. In most US zones, the clock ticks from 01:59 back to 01:00, so every minute from 01:00 to 01:59 happens twice. A wall-clock time in that window maps to two Unix timestamps; converters and schedulers must specify "earlier" or "later".
- Should I use moment.js for new code?
- No. moment.js is in maintenance mode and the project explicitly recommends moving off it for new code. Use Intl.DateTimeFormat for formatting, and Temporal (or its polyfill) for arithmetic. date-fns-tz and Luxon are also reasonable replacements.
- When should I use Temporal instead of Intl.DateTimeFormat?
- Use Intl.DateTimeFormat when you just need to display a Date in a timezone. Use Temporal when you need to do arithmetic — adding days across DST, comparing zoned instants, or constructing an instant from a wall-clock time in a non-UTC zone. Temporal reached TC39 Stage 4 in March 2026 and ships in Chrome 144, Firefox 139, and Node.js 26 by default.