How to Get User Timezone Reliably in Frontend JS

Read it once with Intl.DateTimeFormat().resolvedOptions().timeZone, validate it, memoize it, and persist it — never re-derive a zone from getTimezoneOffset(). Part of Safe Timezone Detection in Browsers, this guide is the concrete frontend recipe: a hook that survives hydration and a fallback that does not lie.

Why This Is Tricky

The detection call itself is one line. The trouble is when it runs. In a server-rendered app (Next.js, Nuxt, Remix), the server has no idea what zone the visitor is in — it usually reports UTC. If you render a timestamp on the server using the host zone and then re-render on the client using the detected zone, React/Vue diff the two HTML trees, see different text, and throw a hydration mismatch. The fix is to render a stable placeholder on the server and only resolve the real zone after the component mounts on the client.

The second trap is the fallback. When Intl is unavailable you might reach for getTimezoneOffset(), but mapping an offset back to a zone is inherently lossy: -300 could be America/New_York (standard) or America/Chicago (daylight), and the Etc/GMT±N zones you can build from it carry no DST rules at all. So the fallback must be treated as an approximation, never as a real zone.

The server can't know the user's zoneDetection is client-onlyThe server can't know the user's zoneassume server host zoneusually UTC — wrongread it in the browserresolvedOptions().timeZoneOnly the browser knows the user's IANA zone; the server must be told or default to UTC.

Hydration Timeline

The diagram shows why detection must wait for mount: the server and the first client paint must produce identical HTML, so the real zone only appears on the second paint.

Hydration-safe detection timeline Server render and the first client paint both show a neutral placeholder so the HTML matches. After mount, useEffect detects the zone and the second paint shows the local time. Server render placeholder First client paint same placeholder (HTML matches) After mount detect + paint local time shown useEffect runs here

Minimal Working Solution

The shortest correct read is a guarded one-liner that returns null instead of guessing when detection fails.

/** Returns the IANA zone, or null when the runtime cannot resolve one. */
export function getUserTimezone(): string | null {
  // resolvedOptions().timeZone is the OS-resolved IANA id, e.g. "Europe/Berlin".
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
  return tz && tz !== 'undefined' ? tz : null; // empty string in old WebViews
}

One call in the browserThe reliable APIOne call in the browsernew Date().getTimezoneOffset()minutes only, no IANA idIntl…resolvedOptions().timeZone'America/New_York'resolvedOptions().timeZone returns the IANA identifier, not just a numeric offset.

Full Production Version

The reliable primitive is Intl.DateTimeFormat().resolvedOptions().timeZone, which returns the OS-resolved IANA identifier (like Europe/Berlin) the browser is configured for. A production helper wraps it to return the zone or null when the runtime cannot resolve one, so callers have a defined fallback path rather than a thrown error or an undefined leaking through. This detected zone is correct for the overwhelming majority of users and is the right default for formatting times in "the user's zone" without asking them.

But detection is a default, not a source of truth, and the production discipline is to allow an explicit user override to win. A traveler, a VPN user, or someone whose OS zone is simply wrong needs to be able to set their zone, and that stored preference should take precedence over detection. The robust order is: use an explicit user setting if present, else the detected IANA zone, else a sensible fallback (often UTC) — and, critically, store times in a way that lets you re-render in whatever zone turns out to be right, rather than baking the detected zone into stored data. Detection tells you how to display, not how to store.

The production version memoizes (so a render pass is internally consistent), validates the result against the runtime's known zones, and exposes the result through a hydration-safe React hook.

'use client';
import { useEffect, useState } from 'react';

let cached: string | null = null;

// Known-zone set, built once. Empty when supportedValuesOf is missing.
const KNOWN = new Set<string>(
  typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : [],
);

function isValid(tz: string): boolean {
  if (KNOWN.size) return KNOWN.has(tz);
  try {
    new Intl.DateTimeFormat('en-US', { timeZone: tz }); // throws RangeError if unknown
    return true;
  } catch {
    return false;
  }
}

/** Detect → validate → memoize. 'UTC' is the single explicit fallback. */
export function getUserTimezone(): string {
  if (cached) return cached;
  let tz: string | undefined;
  try {
    tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
  } catch {
    tz = undefined; // Intl missing on a very old runtime
  }
  cached = tz && isValid(tz) ? tz : 'UTC';
  return cached;
}

/**
 * Hydration-safe: returns null on the server and first paint, then the real
 * zone after mount, so server and client HTML match during hydration.
 */
export function useUserTimezone(): string | null {
  const [tz, setTz] = useState<string | null>(null);
  useEffect(() => {
    const zone = getUserTimezone();
    setTz(zone);
    // Persist for the next request so SSR can render the right zone directly.
    document.cookie = `tz=${encodeURIComponent(zone)}; path=/; max-age=31536000; SameSite=Lax`;
  }, []);
  return tz; // render a placeholder while null
}

Detect, validate, persistRead the zone, validate it, store it against the userDetect, validate, persistbrowserresolvedOptions().timeZonevalidateIANA idpersist +send to server

Verification

// Prove the fallback never returns a bogus zone and validation rejects junk.
import assert from 'node:assert';

// A real zone is accepted unchanged.
assert.ok(isValid('America/New_York'));
// A typo / hostile value is rejected, so getUserTimezone() would fall back to 'UTC'.
assert.equal(isValid('Etc/Nowhere'), false);
// Memoization: two reads return the identical string within one runtime.
assert.equal(getUserTimezone(), getUserTimezone());
console.log('timezone detection verified');

Run it across host zones to confirm zone-dependent code paths: for z in UTC Asia/Kolkata Pacific/Chatham; do TZ=$z node --test; done.

Detection assertionsA valid IANA id is produced and round-tripsAssertions that prove the edge caseresolvedOptions().timeZoneIANA stringis a valid zoneyessend to serverstoredSSR defaultUTC

Common Pitfalls

The first pitfall is treating detection as infallible truth rather than a good default; the OS zone can be wrong, spoofed, or affected by a VPN, so always allow an explicit user override to take precedence. The second is baking the detected zone into stored data — persisting a local time or a fixed offset derived from detection — which corrupts the record if the detection was wrong or the user later corrects their zone; store the instant (and, where wall-clock intent matters, the zone) and derive the display zone at render time. The third is deriving an offset from detection and persisting that; an offset is a stale snapshot, while the IANA zone recomputes correctly across DST.

A fourth pitfall is server-side rendering assuming the server's zone is the user's — 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 explicitly. A fifth is failing to handle the null/unresolvable case, letting an undefined zone flow into a formatter and either throw or silently fall back to the host zone; provide an explicit fallback. Treating detection as a default that an explicit setting can override, and never storing the detected zone as truth, avoids the whole cluster.

Detection pitfallsGuessing the zone from the offsetWrongRightmap getTimezoneOffset → zonemany zones share offsetIntl resolvedOptions().timeZoneexact IANA iddetect on the serverhost zone, not userdetect in browser, persistreuse server-side

FAQ

Why not just use getTimezoneOffset()?

It returns one signed minute offset for the current instant, with the sign inverted relative to ISO. It cannot tell apart zones that share an offset and goes stale across DST, so it cannot identify an IANA zone. Read resolvedOptions().timeZone instead.

How do I avoid the Next.js hydration warning?

Do not detect on the server. Render a neutral placeholder, call detection inside useEffect (or onMounted), and update state after mount so the server HTML and first client paint match. For later requests, send the persisted cookie so the server can render the correct zone immediately.

Is Temporal.Now.timeZoneId() ready to replace this?

It is the cleaner call — no formatter to construct — and feeds straight into Temporal.ZonedDateTime. Until native support is universal, use Intl.DateTimeFormat().resolvedOptions().timeZone as the primary read with the @js-temporal/polyfill as progressive enhancement.

How do I reliably get the user's time zone in frontend JavaScript?

Read Intl.DateTimeFormat().resolvedOptions().timeZone, which returns the OS-resolved IANA identifier like 'Europe/Berlin'. Wrap it to return null when the runtime cannot resolve one so callers have a defined fallback. This is correct for the vast majority of users and is the right default for displaying times in the user's zone — but treat it as a default that an explicit user setting can override, not as infallible truth.

Should I trust the detected time zone as the source of truth?

No. The detected zone is a good default but can be wrong, spoofed, or affected by a VPN, and a traveler may need a different one. Let an explicit user preference take precedence: use the stored setting if present, else the detected IANA zone, else a fallback like UTC. Never bake the detected zone into stored data — store the instant and derive the display zone at render time.

Why not store a time-zone offset from detection?

Because an offset is a stale snapshot that is wrong half the year across daylight saving, whereas the IANA identifier names the full rule set and recomputes the correct offset for any instant. Also, if the detection was wrong or the user later corrects their zone, a persisted offset or local time corrupts the record. Store the instant (and the zone when wall-clock intent matters) and format in the resolved zone on demand.