Safe Timezone Detection in Browsers
Detecting the visitor's timezone correctly is the difference between a calendar that shows the right meeting time and one that silently slips an hour every spring. Part of Intl API & Legacy Date Patterns, this guide covers how to read a canonical IANA identifier from the browser, validate it, fall back safely, and persist the user's choice without breaking server-side rendering.
What Actually Breaks
The classic failure is reading Date.prototype.getTimezoneOffset() and treating the resulting number as a timezone. That number is a signed minute offset for this exact moment β not a zone. Three concrete bugs follow from it: a scheduled reminder fires an hour off after a daylight-saving transition because the cached offset went stale; two users in Asia/Singapore and Australia/Perth are treated as identical because both report +08:00; and a server-rendered page prints 09:00 while the rehydrated client prints 04:00, throwing a React hydration mismatch. Each stems from the same root cause: an offset is a lossy projection of a zone, and the only reliable artifact is the IANA identifier itself.
Detection Flow
The diagram below shows the full pipeline this guide builds: read the resolved zone, validate it against the IANA list, fall back when it is missing, then persist the user's choice so later requests do not need to re-detect.
API Reference
| API | Returns | Timezone caveat |
|---|---|---|
Intl.DateTimeFormat().resolvedOptions().timeZone |
IANA string ("Europe/Berlin") |
Reflects OS setting, not geolocation; may be undefined in old/headless envs |
Date.prototype.getTimezoneOffset() |
minutes, sign inverted (positive = west of UTC) | Single offset for now; cannot identify a zone or survive DST |
Intl.supportedValuesOf('timeZone') |
string[] of IANA zones the runtime knows | Baseline 2023+; use to validate detected identifiers |
Temporal.Now.timeZoneId() |
IANA string | Synchronous, no formatter to construct; needs polyfill today |
Approach A: Legacy Date
Before Intl shipped timezone support, the only signal available was the numeric offset. It is worth understanding precisely because so much legacy code still leans on it β and why that code is wrong.
// Legacy detection: returns a number, not a zone.
const offsetMinutes = new Date().getTimezoneOffset(); // e.g. -120 for UTC+2 (sign inverted!)
const utcOffsetHours = -offsetMinutes / 60; // flip sign to get conventional +2
// You can approximate a fixed-offset zone, but it has NO DST rules:
const approxZone = offsetMinutes === 0
? 'UTC'
: `Etc/GMT${offsetMinutes > 0 ? '+' : '-'}${Math.abs(offsetMinutes) / 60}`; // POSIX sign is inverted again
The limitations are structural, not cosmetic: the offset describes a single instant, so caching it breaks at the next DST boundary; many zones share an offset, so it cannot round-trip to an identifier; and the Etc/GMTΒ±N zones it maps to are fixed and DST-free. Teams still depending on this should read Legacy Date Methods vs Modern Alternatives for the broader migration picture, and the mechanics of the sign convention are covered in Timezone Offset Math Explained.
Approach B: Intl and Temporal
The correct primitive is Intl.DateTimeFormat().resolvedOptions().timeZone, which returns the host's resolved IANA identifier straight from the OS timezone database.
/**
* Reads the canonical IANA identifier the runtime resolved.
* Returns null (not a guessed zone) when detection fails, so callers
* can decide their own fallback rather than silently trusting 'UTC'.
*/
export function detectTimezone(): string | null {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Older WebViews and restricted CSP contexts can return '' or undefined.
return tz && tz !== 'undefined' ? tz : null;
} catch {
return null; // Intl unavailable (very old runtime)
}
}
Temporal.Now.timeZoneId() is the same idea without the formatter detour. Detection and arithmetic share one type system, so a detected zone drops straight into a ZonedDateTime:
import { Temporal } from '@js-temporal/polyfill';
// Synchronous, allocation-light: no Intl.DateTimeFormat instance to build.
const zone = Temporal.Now.timeZoneId(); // e.g. "America/New_York"
// The detected zone feeds DST-safe arithmetic directly.
const now = Temporal.Now.zonedDateTimeISO(zone);
const inOneDay = now.add({ days: 1 }); // keeps wall-clock 09:00 across a DST shift, unlike add({ hours: 24 })
Cross-browser support for resolvedOptions().timeZone covers Chrome 24+, Firefox 29+, Safari 10+, and Edge 79+, plus every maintained Node release. Only IE11 and pre-2018 mobile WebViews need the offset fallback.
Production Implementation
The production primitive for zone detection is Intl.DateTimeFormat().resolvedOptions().timeZone, which returns the OS-resolved IANA identifier the browser is configured for. Wrap it in a helper that returns the zone or null when the runtime cannot resolve one, so callers have a defined fallback rather than an undefined leaking into a formatter. This detected zone is correct for the vast majority of users and is the right default for rendering times in "the user's zone" without prompting them β but it is a default, not a source of truth.
The production discipline is a clear precedence order: an explicit user setting wins, then the detected IANA zone, then a sensible fallback such as UTC. A traveler, a VPN user, or someone whose OS zone is simply misconfigured needs to override detection, and a stored preference should take precedence. Critically, detection tells you how to display, not how to store: persist the instant (and the zone when wall-clock intent matters), and derive the display zone at render time, so a wrong detection or a later correction never corrupts stored data. Baking the detected zone β or worse, an offset derived from it β into persisted records is the mistake that turns a display default into permanent data corruption.
Server-side rendering adds a wrinkle: the server has no reliable access to the browser's zone at first render, so either defer zone-dependent formatting to the client after hydration, or pass the detected zone up from the client explicitly. Assuming the server's own zone is the user's is a reliable way to render every timestamp an hour or more off for users elsewhere.
A real detector validates the result against the runtime's own IANA list, memoizes it to avoid hydration drift, and exposes a single explicit fallback. Intl.supportedValuesOf('timeZone') gives the authoritative list the engine actually understands.
let cached: string | null = null;
// Build the validation set once; it is stable for the page's lifetime.
const KNOWN_ZONES: ReadonlySet<string> = new Set(
typeof Intl.supportedValuesOf === 'function'
? Intl.supportedValuesOf('timeZone')
: [], // empty set => isValidZone falls back to a runtime probe below
);
/** True when the identifier is one the runtime can actually format. */
export function isValidZone(tz: string): boolean {
if (KNOWN_ZONES.size > 0) return KNOWN_ZONES.has(tz);
try {
// Probe: constructing a formatter with an unknown zone throws RangeError.
new Intl.DateTimeFormat('en-US', { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Detect β validate β fall back β memoize.
* Pass a serverHint (from a cookie/header) so SSR and the client agree.
*/
export function resolveTimezone(serverHint?: string): string {
if (cached) return cached;
const candidate = serverHint ?? detectTimezone() ?? 'UTC';
// Reject a corrupt cookie or a zone this runtime does not recognise.
cached = isValidZone(candidate) ? candidate : 'UTC';
return cached;
}
On the server, never call detectTimezone() β the host zone (usually UTC on serverless platforms) has nothing to do with the visitor. Read the persisted hint from the request instead, and only detect on the client after mount. Persisting the choice closes the loop:
// Run after detection, client-side only. SameSite=Lax keeps it on top-level navigations.
export function persistTimezone(tz: string): void {
document.cookie =
`tz=${encodeURIComponent(tz)}; path=/; max-age=31536000; SameSite=Lax`;
}
The end-to-end frontend recipe β the memoized hook, hydration-safe rendering, and the cookie round-trip β is worked through in How to Get User Timezone Reliably in Frontend JS.
Edge Cases
The infallibility assumption is the central edge case: the OS zone can be wrong, spoofed, or shifted by a VPN, so treat detection as a good default that an explicit user setting overrides, never as ground truth. The storage edge follows from it β persisting the detected zone, a local time, or a derived offset corrupts the record if detection was wrong or the user later corrects their zone; store the instant and resolve the display zone on demand. An offset is a particularly poor thing to persist, since it is a stale snapshot wrong half the year across daylight saving, while the IANA identifier recomputes correctly.
The hydration edge is the SSR mismatch: rendering a timestamp in the server's zone on the server and the browser's zone on the client produces a hydration mismatch and a flash of the wrong time; defer or pass the zone explicitly. The unresolvable edge is the null case β a runtime that cannot resolve a zone β which must be handled with an explicit fallback rather than letting undefined flow into Intl.DateTimeFormat, where it silently reverts to the host zone. Each of these reduces to the same rule: detect for display, allow an explicit override, and never treat the detected zone as stored truth.
Stale offset after a DST transition
A value cached from getTimezoneOffset() in January is wrong by an hour after the March transition. Caching the identifier is safe because the offset is recomputed per instant; caching the offset is the bug. Store America/Chicago, never -360.
Detected zone is not physical location
A traveler in Tokyo whose laptop is still set to Europe/London reports Europe/London. That is correct for clock display, but wrong for "find restaurants near me." Treat the detected zone as a clock setting, and gate location-sensitive features behind an explicit preference.
Missing or empty resolution
Headless test runners, locked-down CSPs, and ancient WebViews can return '' or undefined. The detectTimezone() guard converts these to null so resolveTimezone() applies its single, visible 'UTC' fallback rather than crashing downstream formatters.
Gotchas & Common Pitfalls
- Offset as identity β comparing
getTimezoneOffset()values to "detect" a zone. Fix: readresolvedOptions().timeZoneand store the IANA string. - Trusting client cookies blind β a tampered or stale
tzcookie crashes server formatters withRangeError. Fix: runisValidZone()againstIntl.supportedValuesOf('timeZone')before use. - Detecting during SSR β calling
Intl...timeZoneon the server returns the host zone and guarantees a hydration mismatch. Fix: detect only after mount; pass a hint via cookie/header. - Numeric offsets in the database β they go stale when a government changes DST law. Fix: persist canonical IANA strings and resolve offsets at render time.
- No memoization β re-detecting on every render invites inconsistent values across a render pass. Fix: cache the first valid result.
Testing Checklist
| Scenario | Input (TZ / value) |
Expected |
|---|---|---|
| Standard zone resolves | TZ=America/New_York |
detectTimezone() β "America/New_York" |
| Half-hour offset zone | TZ=Asia/Kolkata |
"Asia/Kolkata", not +05:30 |
| Unknown identifier rejected | resolveTimezone("Mars/Olympus") |
"UTC" |
| Empty resolution falls back | detection returns '' |
resolveTimezone() β "UTC" |
| Server hint wins | resolveTimezone("Europe/Paris") |
"Europe/Paris" (no detection) |
Run the matrix by overriding the host zone per process:
# Exercise zone-dependent logic across representative offsets in CI.
for z in UTC America/New_York Asia/Kolkata Australia/Lord_Howe Pacific/Chatham; do
TZ=$z node --test
done
Frequently Asked Questions
Is Intl.DateTimeFormat().resolvedOptions().timeZone supported everywhere?
Yes in every evergreen browser (Chrome 24+, Firefox 29+, Safari 10+, Edge 79+) and all maintained Node releases. Only IE11 and pre-2018 mobile WebViews need the numeric-offset fallback, which is approximate and DST-free.
How do I validate a timezone string before trusting it?
Check it against Intl.supportedValuesOf('timeZone'), or probe by constructing new Intl.DateTimeFormat('en-US', { timeZone: tz }) inside a try/catch β an unknown zone throws RangeError. Always validate identifiers that arrive from cookies, headers, or user input.
Should I store the IANA string or the UTC offset?
Store the IANA string (Australia/Sydney). Offsets change when DST legislation changes and cannot distinguish zones that currently share an offset; the identifier resolves the correct offset for any instant via the tz database.
How does Temporal change detection?
Temporal.Now.timeZoneId() returns the identifier directly with no formatter to build, and the result feeds straight into Temporal.ZonedDateTime for DST-safe arithmetic β removing the bridge the legacy Date approach forced between detection and use.
How do I detect the user's time zone in the browser?
Read Intl.DateTimeFormat().resolvedOptions().timeZone, which returns the OS-resolved IANA identifier like 'Europe/Berlin', and wrap it to return null when the runtime cannot resolve one so callers have a defined fallback. This is correct for most users and is the right default for displaying times in their zone β but treat it as a default an explicit user setting can override, not as infallible truth, and never bake it into stored data.
What precedence should time-zone sources follow?
An explicit user setting first, then the detected IANA zone, then a sensible fallback such as UTC. Detection is a good default but can be wrong, spoofed, or affected by a VPN, and travelers may need a different zone, so a stored preference must be able to override it. Detection tells you how to display, not how to store β persist the instant and resolve the display zone at render time.
Why does time-zone detection cause problems with server-side rendering?
Because the server has no reliable access to the browser's zone at first render, so rendering a timestamp in the server's zone on the server and the browser's zone on the client produces a hydration mismatch and a flash of the wrong time. Either defer zone-dependent formatting to the client after hydration, or pass the detected zone up from the client explicitly, rather than assuming the server's zone is the user's.
Related
- Intl API & Legacy Date Patterns β the parent overview.
- How to Get User Timezone Reliably in Frontend JS β the frontend recipe with a memoized hook.
- Mastering Intl.DateTimeFormat Options β formatting a detected zone correctly.
- Format a Date in a Specific Timezone for Display β overriding the detected zone per view.
- Timezone Offset Math Explained β why the offset sign is inverted.