GitHub - leeoniya/easy-tz: Experiments in implementing a fast, dependency-free getTimeZonesAt(timestamp)

14 min read Original article ↗

Experiments in implementing a fast, dependency-free getTimeZonesAt(timestamp). Scope: current-year accuracy in modern runtimes, plus validated historical offsets back to 1995 for the baked impls (07/10); results are independent of the host timezone (TZ). Pre-1995 accuracy is a non-goal.

function getTimeZonesAt(timestamp: number, withAliases?: boolean): TimeZoneInfo[];
function getTimeZoneAt(name: string, timestamp: number, withAliases?: boolean): TimeZoneInfo | undefined; // one zone, many timestamps
function getTimeZones(withAliases?: boolean): TimeZoneInfo[];           // all zones now; schedule-only, history tree-shakes out
function getTimeZone(name: string, withAliases?: boolean): TimeZoneInfo | undefined; // one zone now; schedule-only, history tree-shakes out
function formatOffset(minutes: number): string; // -300 -> "-05:00"

interface TimeZoneInfo {
  name: string;     // "America/New_York"
  abbr: string;     // "EST" / "EDT" (not "GMT-5" where avoidable)
  offset: number;   // signed minutes east of UTC, e.g. -300 (formatOffset() -> "-05:00")
  aliasOf?: string; // canonical id when `name` is a legacy spelling ("Asia/Kolkata")
}

Why this exists

┌──────────────────────────────────────────────────────────────┐
│ Type to search (name, city, abbreviation)                 🔍 │
├──────────────────────────────────────────────────────────────┤
│  Default  UTC, GMT                             [UTC+00:00]  ▲│
│  Browser Time  CDT                             [UTC−05:00]  █│
│  Coordinated Universal Time  UTC, GMT          [UTC+00:00]  █│
│ ──────────────────────────────────────────────────────────  ░│
│  Africa                                                     ░│
│    Abidjan  GMT                                [UTC+00:00]  ░│
│    Accra  GMT                                  [UTC+00:00]  ░│
│    Addis Ababa  EAT                            [UTC+03:00]  ░│
│    Algiers  CET                                [UTC+01:00]  ░│
│    Asmara  EAT                                 [UTC+03:00]  ▼│
└──────────────────────────────────────────────────────────────┘

While swapping a codebase from 295KB moment to 68KB luxon, I also wanted to drop the 770KB moment-timezone dependency from a time zone picker component. A small, fast replacement did not exist for this purpose (see comparison.md for the full 19-library evaluation):

  • Intl provides offsets, but not reliable abbreviations: en CLDR only defines short names for a handful of mostly North American metazones, so Intl-backed formatters (luxon, date-fns, dayjs) emit "GMT+2"-style labels for most of the world.
  • Relying on Intl at runtime is also slow to initialize and memory-heavy: constructing a formatter per zone is ~100x the cost of calling one, so the first full-list call pays tens of milliseconds and tens of MB of ICU state.
  • Libraries with real abbreviations built in (moment-timezone, timezone-support, timezonecomplete, bigeasy/timezone) bundle full tzdata — 0.3-1.8 MB minified — carrying deep pre-modern transition data (sub-minute 19th-century offsets, every pre-1970 regime) this use case doesn't need. The baked impls here instead bake a bounded, offsets-only 1995+ window (see Historical coverage).

My first attempt split the difference with a generated offset→abbreviation lookup plus live Intl offsets. The implementations here further explore the full live-to-baked spectrum, ending in 07-baked-rules: vs moment-timezone it cuts cold start ~32x (22.2ms → 0.7ms) and memory ~2.8x (23.1MB → 8.2MB) at ~3% of the bundle size (768KB → 24.8KB), while passing all 62 edge-case fixtures and improving abbreviation coverage for 159 zones where modern tzdata dropped letter abbreviations (Santiago CLT/CLST, Kathmandu NPT, Chatham CHAST/CHADT, Kiritimati LINT, Lord Howe LHST/LHDT, Istanbul TRT, …).

Implementations

Ordered fastest to slowest — which is also most-baked to most-live: each step down trusts the generated data less (adding runtime rigor and cost), until 04-live-intl ships no generated data at all.

impl trust model cold ms miss ms rss MB bundle KB
07-baked-rules trusts baked tables completely 0.7 <0.1 8.2 24.8
10-audited-rules baked tables, Temporal-audited at first call; failing zones recovered live 6.9 <0.1 10.3 26.9
08-verified-sharing live Intl values; baked data only hints formatter sharing, Temporal-verified at first call 24.1 0.7 20.7 12.0
04-live-intl fully live — no generated data to trust 44.1 1.4 27.6 7.6

Full-list getTimeZonesAt(), measured on chrome-headless-shell (the primary target) via bun run bench. cold is the first call (median over several fresh page contexts); miss an hour-bucket recompute (median over time-budgeted samples); hit (not shown) is a memoized repeat and free at this scale — single-digit nanoseconds, i.e. 0.0ms. bundle is minified, not gzipped (07/10 carry the 1995+ history eras; gzip roughly halves them: 07 ≈ 12.1KB).

Single-zone lookups

getTimeZoneAt(name, timestamp) resolves one zone without building the full list — the single-zone / many-timestamps counterpart. Same ordering; each column is the wall time to sweep America/New_York across 10,000 timestamps (6h step), once in a current (projected) year and once in a historical one, taking the fastest of several repeated passes. The repeats matter: a single pass measures the engine's JIT ramp as much as the impl, which read the baked sweeps 4-5x high under V8 (and up to 25x under JSC), because the ramp's roughly fixed cost swamps a sweep this cheap.

The two timing columns below still carry that ramp — they were captured before the repeated-pass fix landed. Re-run bun run bench on an idle machine to refresh them; expect the baked rows to drop severalfold and the live rows, whose own work dominates, to stay put.

impl 10k cur ms 10k hist ms formatters
07-baked-rules 2.5 3.3 0
10-audited-rules 2.7 17.0 0
08-verified-sharing 34.1 32.1 1
04-live-intl 35.1 33.4 1

Baked history costs 07 little extra — the whole 10k-instant sweep runs in a couple of ms whether the instants are past or present. On a Temporal runtime 10 resolves the past live (Temporal is authoritative for history), hence its much heavier historical sweep; the live impls build one formatter for the zone and reuse it across the whole sweep either way.

Schedule-only route (getTimeZones() / getTimeZone())

The two current-instant entry points take no timestamp — they answer Date.now(), which is always the bake year or later, so they never need the historical eras. On the baked impls (07/10) both are wired through a history-free code path (shared/bakedSchedule.ts, with the eager history decode marked /*@__PURE__*/), so a bundler that sees you import only current-instant APIs drops the entire 1995+ history table. Measured on the shipped dist/ (consumer bundle, minified):

import 07 KB 10 KB
getTimeZonesAt (history-capable) 23.4 25.0
getTimeZoneAt (history-capable) 23.2 24.7
getTimeZones (schedule-only) 11.5 12.8
getTimeZone (schedule-only) 11.3 12.6

Roughly halved — the ~12KB of baked eras tree-shake away. Import getTimeZonesAt/getTimeZoneAt anywhere and the history comes back. On the live impls (04/08) there's no history to shed; getTimeZones() is just getTimeZonesAt(Date.now()) sharing the same hour-bucket memo, and getTimeZone() is getTimeZoneAt(name, Date.now()).

The four resolvers are a 2x2 grid — all zones or one, at a given instant or now — and every cell agrees with the others by construction: they funnel through one per-zone core (tests/single-zone.test.ts, tests/get-timezones.test.ts pin this for every zone).

Implementation details — strategies and per-impl notes
impl abbr strategy offset strategy
07-baked-rules baked into generated year schedule baked into generated year schedule (zero Intl at runtime)
10-audited-rules 07's baked schedule, audited at first call baked (audited); Temporal-live for recovered zones
08-verified-sharing same as 04, via rep formatters whose groups are Temporal-verified at first call same as 04
04-live-intl 'long' name -> curated map, initials fallback derived arithmetically from zone-local wall-clock fields (1 Intl call/zone)

07-baked-rules trusts the generated data completely: the generator emits shared/schedule.ts — a YEAR-INDEPENDENT schedule fitted by probing three consecutive years: static states, two-state nth-weekday-of-month rules ("second Sunday of March at 02:00 wall"), and current-year segments for the few zones whose rules aren't Gregorian (Morocco/Palestine Ramadan rules) — so a call is pure date math with zero Intl usage, and stays correct across year boundaries until a country actually changes policy. Fastest cold start and smallest memory of the four, but least resilient: a stale table means wrong answers until regeneration (needed on tzdata/CLDR changes, and yearly only for the irregular zones). tests/schedule.test.ts asserts output-equality with 04 including next-year instants; irregular zones clamp outside the generated year.

How exposed is that in practice — measured against 04, not against perfect data? The events are real but rare: weighting the last decade of tzdb releases (2016-2026) by who's affected, a future-effective rule change hit a 2M+ metro zone in 7 of 11 years — Cairo twice (2016 DST cancel on 3 days' notice, 2023 reintroduction), Istanbul (2016), Casablanca and Pyongyang (2018), Brazil's DST-observing zones incl. São Paulo and Rio (2019), the 2022 cluster (Mexico City, Tehran, Amman, Damascus, Santiago), Almaty and Asunción (2024), Calgary/Edmonton (2026) — ~1.5-2 major zones/year, heavily clustered; 2021 and 2025 touched only small-population zones (Samoa, South Sudan, Chilean Aysén). But 04 is not current at the effective date either: its data rides announcement -> tzdb release (days-weeks, sometimes negative — Alberta 2026 shipped 3 weeks after taking effect; Egypt 2016 gave 3 days) -> ICU/Chrome pickup (a stable cycle or two on a 4-week cadence) -> each user's browser actually updating. That shared upstream pipeline is weeks-to-months; 07's additional exposure is only how long regen+redeploy lags the generating Chrome's update, which for any app that deploys monthly-or-better rounds to zero. It can even invert: baked output doesn't depend on the user's runtime, so a freshly regenerated table serves correct post-change data to browsers whose own ICU is still stale — where 04 is wrong. The one structural exception is predictable, not event-driven: the Ramadan-rule zones (Casablanca/El Aaiun and Gaza/Hebron — all of Morocco and Palestine, ~40M people) clamp outside their generated year, so skipping the January regen gets them wrong for the ~month-long Ramadan window every single year, no policy change required.

10-audited-rules builds on 07, adding a first-call audit for rigor: once per process (sound — browsers never hot-swap tzdata) every zone's current-year behavior predicted by the baked schedule is checked against Temporal's exact transition walk (~2-5ms, no formatters). Zones that fail — a policy change in a stale table, unknown zones, irregular zones outside their generated year — are recovered for the session with live Temporal offsets and generic GMT-style labels; everything else runs pure baked at 07's miss cost. Never a wrong offset on Temporal runtimes; without Temporal (Safari and runtimes built without the component) it degrades to exactly 07. (It superseded 09-guarded-hybrid, which achieved the same protection with a per-call guard and a bundled live-Intl fallback: ~0.8ms misses and +3.4KB for curated-quality recovery labels.)

08-verified-sharing applies the same verify-at-first-call idea but flips the trust model: values always come from live Intl, and the generated class table is demoted to a hint about which zones can share one formatter (180 formatters instead of 433, cutting 04's cold start by ~45%). At first call each group member's exact offset behavior for the year is compared against its representative's via Temporal's transition walk (getTimeZoneTransition, no formatters, ~4-5ms once), and diverged members are split out to format themselves. One-time cost, no per-call overhead; a stale table can only cost speed, never correctness. Without Temporal it degrades to exactly impl 04.

04-live-intl is the fully live baseline: no generated data at all — a curated long-name -> abbreviation map plus one Intl formatter per zone, with offsets derived arithmetically from zone-local wall-clock fields. Slowest cold start and heaviest memory (one formatter per zone forces the full ICU cost), but nothing can go stale except the small curated abbr map; it's the reference the other three are tested against.

Historical coverage (1995+)

The baked impls answer timestamps before the bake year (back to 1995), not just projecting the current rules backward. 07 (and 10 on non-Temporal runtimes) resolves them through validated historical offset eras in shared/history.ts — a compact, offsets-only encoding of each zone's past DST regimes (e.g. the pre-2007 US rule, decree-driven one-off years). Offsets are exact; the label reuses the schedule class's abbreviation when the offset matches one of its states (the common "same abbreviations, different DST dates" case, like EST/EDT before 2007) and otherwise falls back to a GMT-style label — historical CLDR abbreviations aren't baked. On a Temporal runtime, 10 instead resolves the past live (Temporal is authoritative for history), and the live impls (04/08) always get history straight from Intl, so none of them need the baked eras.

This window is deliberately bounded: tools/sweep-validity.ts checks every zone against the runtime's own ICU for each year from 1995 to the bake year (plus a couple ahead), and the eras are what makes those years exact. It's also the bulk of what makes 07/10 larger than 04/08 — a few KB of era data buys zero-Intl historical correctness. Pre-1995 timestamps clamp to the earliest era rather than erroring, but aren't validated.

All impls memoize the full response per UTC hour bucket (shared/hourCache.ts): a single global slot keeps the last bucket's result and is refreshed whenever a timestamp falls outside it, so only same-bucket repeats hit — suited to clock-driven queries near "now". The underlying compute always runs at the bucket start, so DST transitions (hour-aligned in UTC for nearly all zones) resolve deterministically at bucket boundaries. Cache hits return the same array reference — treat results as immutable. Hits cost ~0.01-0.3µs vs a miss's ~0.7-5ms (live impls) or <0.1ms (baked impls); tests/cache.test.ts benches hit and miss loops separately for every impl.

Install

npm install @leeoniya/easy-tz

Usage

import { getTimeZonesAt, getTimeZoneAt, getTimeZones, getTimeZone, formatOffset } from '@leeoniya/easy-tz';

const zones = getTimeZonesAt(Date.now());
// [
//   { name: 'Africa/Abidjan',     abbr: 'GMT', offset: 0 },
//   ...
//   { name: 'America/New_York',   abbr: 'EDT', offset: -240 },
//   ...
// ] — every IANA zone the runtime knows, sorted by name

// same list at the CURRENT instant, no timestamp arg. On the baked root this
// is the schedule-only route: import ONLY this and the 1995+ history table
// tree-shakes out (~halves the bundle — see Schedule-only route above).
getTimeZones();

// resolve a SINGLE zone — the one-zone / many-timestamps counterpart, with
// no full-list allocation. Unknown names return undefined.
getTimeZoneAt('America/New_York', Date.now());
// { name: 'America/New_York', abbr: 'EDT', offset: -240 }

// the single-zone getters also accept the fixed-offset ids that ICU accepts
// but doesn't enumerate (Chrome lists none of them), so they're absent from
// the list above: UTC, Etc/UTC, and Etc/GMT+1..+12 / Etc/GMT-1..-14. Note the
// POSIX sign inversion — Etc/GMT+5 is UTC-05:00, not +05:00.
getTimeZoneAt('Etc/GMT+5', Date.now());
// { name: 'Etc/GMT+5', abbr: 'GMT-5', offset: -300 }

// same single zone at the CURRENT instant, no timestamp arg — schedule-only
// like getTimeZones(), so a picker that only ever asks about "now" can import
// just these two and ship neither the history table nor a timestamp.
getTimeZone('America/New_York');
// { name: 'America/New_York', abbr: 'EDT', offset: -240 }

// all four take a trailing `withAliases` (default true). Pass false to keep
// legacy spellings out of the results entirely — see Aliases below.
getTimeZones(false);                          // drops the 20 aliasOf entries
getTimeZone('Asia/Calcutta', false);          // { name: 'Asia/Kolkata', abbr: 'IST', offset: 330 }

formatOffset(-240); // "-04:00" — render offset minutes as an ISO-style string

The root import is 07-baked-rules — fastest and smallest, pure baked data (see Implementations). The other impls are available as subpath imports with the same API, in increasing order of runtime verification (and cost):

import { getTimeZonesAt } from '@leeoniya/easy-tz/10-audited-rules';    // baked, Temporal-audited at first call
import { getTimeZonesAt } from '@leeoniya/easy-tz/08-verified-sharing'; // live values, verified sharing
import { getTimeZonesAt } from '@leeoniya/easy-tz/04-live-intl';        // fully live baseline

Full-list results are memoized per UTC hour bucket and returned by reference — treat them as immutable. The single-zone resolvers aren't memoized and don't need to be: every TimeZoneInfo is an interned, frozen instance shared across calls, so resolving one zone allocates nothing. Every entry also exports clearCache(), which drops that memo so the next call recomputes (first-call init/verification work is not redone); it exists for test/bench harnesses and for recovering from accidental mutation of a returned array.

Aliases (withAliases)

Twenty IANA ids are legacy spellings of another zone — Asia/Calcutta for Asia/Kolkata, America/Buenos_Aires for America/Argentina/Buenos_Aires, and so on. Runtimes disagree about which spelling they enumerate (Chrome lists several of the legacy ones, bun lists the modern ones), so the response always contains both, with the legacy entry tagged aliasOf. That keeps search matching on either spelling working, but it puts near-duplicates in a picker.

Passing withAliases: false opts out of legacy-spelled results everywhere:

getTimeZonesAt(ts, false);  // 20 fewer entries — the aliasOf ones are dropped
getTimeZones(false);        // same, at the current instant

// the single-zone getters can't drop anything, so they substitute instead:
// a legacy name resolves as its canonical zone
getTimeZoneAt('Asia/Calcutta', ts, false);
// { name: 'Asia/Kolkata', abbr: 'IST', offset: 330 }

No result ever carries an aliasOf when the flag is off. Note the asymmetry the substitution implies: the returned name is the canonical spelling, not the one you passed, so don't use it to key a map by the requested id. Canonical and fixed-offset names are unaffected; unknown names return undefined with either flag value.

Both paths are cheap enough to use freely. Dropping entries doesn't break the by-reference contract — the filtered array is derived once per hour bucket alongside the full one and shares its TimeZoneInfo instances, so repeat calls return the same array. And because instances are interned by name, getTimeZoneAt('Asia/Calcutta', ts, false) returns the very same object as getTimeZoneAt('Asia/Kolkata', ts).