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.

The server guesses the wrong zoneHost zone β‰  user zoneThe server guesses the wrong zonerender with server host zoneusually UTC β€” wrongdetect in browser, persistuser's real IANA zoneServer-side rendering can't know the user's zone; detect client-side and store it.

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

Detection APIsHow to obtain the user's zoneDetection APIsIntl…resolvedOptions().timeZoneIANA id, e.g. 'America/New_York'getTimezoneOffset()minutes only, ambiguouspersist to profile/cookiereuse on the serverdefault to UTCwhen unknown (SSR)

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.

Legacy detectiongetTimezoneOffset gives minutes, not a zoneLegacyModerngetTimezoneOffset()→ 300 (minutes)many zones share itcannot name the zoneresolvedOptions().timeZone→ IANA idunique identifierreliable

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.

Detect, validate, useRead the IANA zone, validate, build ZonedDateTimeDetect, validate, useresolvedOptionstimeZonevalidate idZonedDateTime

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.

Detect once, reuse everywherePersist the zone; server renders with the stored idDetect once, reuse everywherebrowserdetectpersistserver uses id

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.

Detection edge casesWhere detection is unreliableDetection edge casesSSR first paintno browser yetdefault UTCVPN / spoofingoffset misleadsconfirm with userZone changestravel / DSTre-read on load

Gotchas & Common Pitfalls

Detection pitfallsOffset-based guessingWrongRightoffset β†’ zone lookupambiguousresolvedOptions().timeZoneexact IANA iddetect on serverhost zonedetect client, persistreuse server-side

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

Detection test matrixA valid IANA id is produced and storedAssertions that prove the edge caseresolvedOptionsIANA stringvalidate idtruepersistedround-tripsSSR fallbackUTC

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.