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.

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
}

Full Production Version

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
}

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.

Common Pitfalls

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.