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
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
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.
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.