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.

Safe timezone detection pipeline Intl.DateTimeFormat resolvedOptions timeZone feeds a validation step against the supported IANA list. Valid zones proceed to persistence; invalid or missing zones route to a UTC fallback before persisting the user choice. Intl.DateTimeFormat() .resolvedOptions().timeZone Validate against IANA list Persist user choice cookie / profile Fallback: 'UTC' or Etc/GMT approx valid missing / invalid recover

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

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.