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.
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
-
Detecting during render instead of after mount.
// Wrong: runs on the server too -> hydration mismatch. const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;// Right: resolve after mount, render a placeholder until then. const tz = useUserTimezone(); // null on server + first paint -
Guessing a zone from the offset.
// Wrong: an offset is not a zone; -300 maps to several IANA zones. const zone = `Etc/GMT${new Date().getTimezoneOffset() / 60}`;// Right: read the identifier; only approximate when Intl is truly absent. const zone = getUserTimezone(); -
Skipping validation on a value from a cookie or query string. Pass it through
isValid()first; an unknown zone makesIntl.DateTimeFormatthrowRangeErrorat render time. -
No memoization, so repeated reads in one pass can disagree. Cache the first valid result.
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.
Related
- Safe Timezone Detection in Browsers — the parent guide covering validation and persistence in depth.
- Mastering Intl.DateTimeFormat Options — once you have the zone, format with it.
- Timezone Offset Math Explained — the inverted-offset sign convention in detail.