Back to Knowledge Hub
Fingerprint

ICU Timezone Aliases and the TZ You Actually Report

Ask ICU which timezone identity a page really reads: how aliases collapse, when TZ wins, and why a fixed offset matches a DST zone in January and diverges in July.

BotBrowser Team

Documentation

Want the structured docs for Fingerprint?

This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.

BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping bounded observations about what a page reports, but it cannot make ICU return a name it was not built with, change which spelling a given ICU release treats as canonical, or make a fixed-offset zone behave like a daylight-saving zone just because both read the same clock in January. This review runs the ICU that ships with the Node runtime over a fixed list of timezone names, reports what Intl.DateTimeFormat().resolvedOptions().timeZone answers for each, and then separates three things that get conflated in timezone reviews: the name a page reads, the offset it renders, and the environment variable that supplied the default.

TL;DR

  • Mostly the historical spellings still work, but ICU answers with its own canonical name: Asia/Kolkata reports Asia/Calcutta, Europe/Kyiv reports Europe/Kiev.
  • UTC, Etc/UTC, Etc/GMT, GMT and Zulu all collapse to UTC, so the reported string cannot distinguish them.
  • Setting process.env.TZ changes the default the next Intl call reports; an explicit timeZone option wins for that call and leaves the default unchanged.
  • Etc/GMT+5 and America/New_York render the same clock in January and differ by an hour in July, so one winter sample cannot separate a fixed offset from a DST zone.
  • Offsets are not limited to whole hours: Asia/Katmandu reports GMT+5:45 and Pacific/Chatham reports GMT+13:45.
  • Every claim here is a property of one ICU and tzdata pair; it says nothing about how a site scores a session.

Contents

TZ_ENV feeds DEFAULT_ZONE, an explicit option overrides it, and ICU canonicalizes ASIA_KOLKATA into ASIA_CALCUTTA

Which ICU this run used

The probe prints the ICU and tzdata versions before anything else, because every claim below is scoped to that pair. The run reported icu=78.3, tz=2026a, on node=v26.9.0. Another ICU build can carry a different canonical table: a spelling treated as an alias today can become canonical later, and that has happened as deprecated names were retired.

That version line is the cheapest way to keep a timezone review honest. A report that says a page "reports Europe/Kiev" without saying which ICU and tzdata produced it cannot be rechecked, because the answer is a lookup in a table that ships with the runtime rather than a computation from the clock. Recording both versions turns an anecdote into a reproducible observation, and it also explains why two machines with the same browser version can disagree.

Ask ICU for the canonical name, not the input

The probe passes 21 names through new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone. The interesting property is that the answer is often not the input:

Asia/Calcutta -> Asia/Calcutta
Asia/Kolkata -> Asia/Calcutta
Europe/Kiev -> Europe/Kiev
Europe/Kyiv -> Europe/Kiev
Asia/Rangoon -> Asia/Rangoon
Asia/Yangon -> Asia/Rangoon
Asia/Saigon -> Asia/Saigon
Asia/Ho_Chi_Minh -> Asia/Saigon
Pacific/Ponape -> Pacific/Ponape
Pacific/Pohnpei -> Pacific/Ponape

Each pair is one zone under an older and a newer spelling, and ICU resolves both to a single canonical name. The direction of the collapse matters: the older spelling wins in this build, so a page reporting Asia/Calcutta may well have had Asia/Kolkata configured, and a page reporting Europe/Kiev may have been set to Europe/Kyiv. The reported string is evidence of what this ICU canonicalizes to, not of what the caller supplied.

The collapse is not limited to deprecated names. UTC, Etc/UTC, Etc/GMT, GMT and Zulu all return UTC, and US/Eastern returns America/New_York. Five different inputs become one output, so a comparison that reads only the resolved name cannot tell them apart, and a record that keeps only that field has lost information it can never recover.

Ten of the 21 probed names come back exactly as written, which is more than the pairs above suggest: UTC, Etc/GMT+5, Etc/GMT-5, Asia/Calcutta, Europe/Kiev, America/Buenos_Aires, America/New_York, Asia/Rangoon, Pacific/Ponape and Asia/Saigon. The verbatim group is not "already canonical"; it is "already the spelling this table prefers". The two GMT variants are in it and still carry opposite offsets. They are distinct zones with opposite offsets, and the POSIX sign convention is inverted relative to the usual habit. In the paired test below, Etc/GMT+5 renders 07:00 when UTC is 12:00, while Etc/GMT-5 renders 17:00.

An unknown name fails loudly rather than being normalized: Mars/Olympus throws a RangeError, and so do the nearer misses Asia/Calcuttta, America/New_Yrok, Europe/Kievv and Europe/Berlin1. Only case variants resolve, so asia/kolkata and ASIA/KOLKATA both answer Asia/Calcutta; a name with extra spaces is rejected like any other unknown string. The consequence is narrower than it first looks: a misspelled zone is rejected rather than silently mapped, but a correctly spelled alias is silently remapped, and the page cannot tell the two outcomes apart from the reported string alone. A page that reports a plausible zone has therefore passed one check, that the name exists, and failed nothing, because the name it reports may still differ from what was configured.

Count how many inputs collapse into one bucket

The alias structure is easier to reason about when it is counted rather than read one name at a time. A second pass groups nine candidate inputs by the resolved answer:

bucket Asia/Calcutta <- Asia/Kolkata, Asia/Calcutta
bucket REJECTED <- Asia/Bombay
bucket Asia/Dhaka <- Asia/Dacca, Asia/Dhaka
bucket Europe/Kiev <- Europe/Kyiv, Europe/Kiev, Europe/Uzhgorod, Europe/Zaporozhye

Four findings come out of those four lines. The India zone absorbs two spellings; the long name for Asia/Calcutta is India Standard Time and its offset is GMT+5:30, so the older name does not describe a different country. Mumbai's older Asia/Bombay name is not in this table at all and is rejected outright, which shows the table is not a complete history of every name ever used. Dhaka absorbs the older Asia/Dacca spelling. And the Kyiv zone absorbs four inputs, including two city names in western Ukraine that are geographically different cities but resolve to the same canonical entry.

That last bucket is the one that breaks naive reasoning. A reviewer who assumes the canonical name describes a location would read Europe/Kiev as "the session claims to be in Kyiv". In this build it also covers a session configured as Europe/Uzhgorod, which is roughly 600 kilometres away. The canonical string is a table entry, not a claim about where the machine stands.

The rejected-name case is the useful counterexample for a different reason. If the table accepted every historical alias, then a rejection would be strong evidence that the configuration was wrong. Because it does not, a rejection proves only that this particular spelling is absent from this ICU build. The safe statement is narrow: this input was not accepted here, so the page's reported zone came from some other path.

Where the TZ environment variable actually wins

The second probe separates the default zone from an explicit option, which is where most of the confusion about "what timezone is this browser in" originates.

TZ as inherited: resolved=America/Toronto toTimeString=07:00:00 GMT-0500 offset=GMT-5
TZ set at runtime: resolved=Asia/Tokyo toTimeString=21:00:00 GMT+0900 offset=GMT+9
explicit option wins over env: Europe/Berlin
default after explicit call still: Asia/Tokyo
invalid zone rejected: RangeError
long name part: Eastern Standard Time

Three behaviours are visible in those six lines. Mutating process.env.TZ at runtime changes what the next no-option Intl call reports, in both resolvedOptions() and the rendered toTimeString(), so an environment default is not frozen at process start in this runtime. An explicit timeZone option wins over the environment for that call and leaves the default unchanged for later calls, which still report Asia/Tokyo. And the long timezone name for America/New_York in January is Eastern Standard Time, a name that itself changes with the date.

The consequence for a fixture is that "the page reports zone X" is ambiguous unless the record says how X was supplied. A process started with TZ set and no per-call option reports an environment default; a page passing an explicit option reports a decision made in code. The two produce the same resolvedOptions().timeZone string when they agree, and only the recorded input distinguishes them. That is also why a support report that quotes the zone alone cannot be reproduced: the same string can come from either path, so the first question to ask is where the value entered the process.

There is a second-order trap in the same area. Because TZ is read by the runtime rather than by the page, a value inherited from a shell profile can survive into a test that believed it was exercising a profile-driven default. The probe shows the inherited zone as America/Toronto, which came from the host environment and not from any page option. A fixture that records only the resolved zone would attribute that to the profile under test. Checking the environment before the run, and noting whether the variable was set at launch or mutated later, is the difference between measuring the profile and measuring the shell.

An explicit option also does not permanently rewrite the default. The probe sets a default through TZ, then formats with an explicit zone, then asks a fresh formatter for the default and still receives the environment value. Two consecutive reads in one script can therefore disagree if one of them passed an option, and a comparison that interleaves the two call shapes without labelling them will look like the zone changed between reads.

Read the offset, not only the name

A timezone identity is usually recorded as one string, but the same zone can render six different name shapes depending on how the formatter is asked:

name shortOffset: GMT-5
name longOffset: GMT-05:00
name short: EST
name long: Eastern Standard Time
name shortGeneric: ET
name longGeneric: Eastern Time

These are not interchangeable evidence. GMT-5 and GMT-05:00 carry an offset and no season, so they stay stable across the year. EST and Eastern Standard Time embed the winter designation and would read EDT and Eastern Daylight Time in July. ET and Eastern Time are season-agnostic and match both. A review that records "the page says EST" and later compares it to a summer sample has compared two different labels and may report a false change.

Offsets are also not limited to whole hours, which is a common assumption that fails on real zones:

zone Asia/Kathmandu -> Asia/Katmandu GMT+5:45
zone Australia/Eucla -> Australia/Eucla GMT+8:45
zone Pacific/Chatham -> Pacific/Chatham GMT+13:45
zone Asia/Kolkata -> Asia/Calcutta GMT+5:30

Kathmandu both canonicalizes to a different spelling, Asia/Katmandu, and renders a 45-minute offset. Chatham renders GMT+13:45 in January because its daylight-saving rules are southern-hemisphere. A parser or validator that expects offsets on the hour or expects a positive offset under a certain size will misread all four of these rows while reporting no error.

The locale dimension adds a third variable. The same instant and zone render differently across locales in ways that change both the ordering and the clock convention:

locale en-US: hourCycle=h12 hour12=true -> Jan 15, 2026, 7:00 AM
locale en-GB: hourCycle=h23 hour12=false -> 15 Jan 2026, 07:00
locale de-DE: hourCycle=h23 hour12=false -> 15.01.2026, 07:00
locale fr-FR: hourCycle=h23 hour12=false -> 15 janv. 2026, 07:00
locale ja-JP: hourCycle=h23 hour12=false -> 2026/01/15 7:00

en-US is the only row on a 12-hour cycle, and ja-JP prints 7:00 without a leading zero while the other h23 rows print 07:00. A comparison that normalizes dates as strings across locales will therefore flag four differences that are all formatting, not session state.

Where a fixed offset imitates a DST zone

The last section samples two dates a year apart, which is the cheapest way to separate a fixed offset from a zone that observes daylight saving:

dst America/New_York: 07:00 / 08:00
dst Etc/GMT+5: 07:00 / 07:00
dst Europe/Berlin: 13:00 / 14:00

America/New_York and Etc/GMT+5 both render 07:00 for a January instant, so a single winter sample cannot separate them. In July they differ by an hour, and Europe/Berlin shows the same shift from 13:00 to 14:00. A review that captures one timestamp and calls the timezone consistent has measured agreement on one date, not identity of the zone.

The paired offset test makes the same point from the other direction:

pair Etc/GMT+5 vs America/New_York: 07:00 / 07:00
pair Etc/GMT-5 vs Asia/Karachi: 17:00 / 17:00
pair UTC vs Etc/UTC: 12:00 / 12:00

Each pair agrees on the sampled instant while describing different zones. Two of the three pairs would also agree in July; only the first would diverge, because only one side observes daylight saving. That is why the winter-only comparison is the trap: it produces an agreeing result for every pair in this table, including the pair that will disagree six months later.

If a fixture can afford only one sample, the honest statement is "these two agreed on this date". A second sample in the opposite season costs almost nothing and converts that statement into "these two describe the same offset rules", which is the claim most timezone reviews actually want to make.

The southern hemisphere makes the sampling rule stricter than "pick summer and winter". Pacific/Chatham renders GMT+13:45 in January, which is its summer, so a northern-hemisphere reviewer reading that value as a winter reading has the season backwards. Australia/Eucla keeps a constant GMT+8:45 because it does not shift at all. A pair of samples taken six months apart still separates the two, but only if the record keeps the instant alongside the rendered value instead of labelling one sample "winter" by assumption.

Picking the instants also matters. A sample taken at 12:00 UTC keeps the two dates cleanly comparable, while a sample near local midnight can move the rendered date as well as the rendered hour, and a comparison that only stores the hour will then read the date shift as a missing day. Storing the exact ISO instant removes both ambiguities at no extra cost.

Timezone is one field beside two neighbours that follow different rules, so a fixture that records only this one is incomplete. Timezone, locale and language covers how the three interact, and navigator language and application localization shows that the language list has its own canonicalization problem, where an ordered array is compared against a single negotiated header.

Read a timezone identity without overclaiming

A timezone reading supports a narrow statement: this ICU build, with this input, reported this canonical name and this rendered offset for this instant. It does not state which alias was configured, whether the zone observes daylight saving, which locale produced the rendered string, or how a site treats a session that reports it.

Keep five fields in the record and the reading stays checkable. The ICU and tzdata versions, because the canonical table and the DST rules both ship with them. The input name as supplied, because canonicalization discards it. The sampled instant, because a fixed offset and a DST zone agree in winter. The call shape, because an environment default and an explicit option both fill the same output field. And the locale with its hourCycle, because the rendered string depends on both.

The matching docs page covers how BotBrowser keeps timezone, locale and language aligned with the loaded profile. This review adds the reading side: what ICU does with the names those settings produce, which spellings collapse into one answer, how many inputs share a bucket, and where a single sample cannot distinguish two different zones. Consistent configuration and a correct interpretation of the reported string are separate questions, and a fixture that only records the resolved name answers neither of them completely.

For any decision about a real session, use an authorized integration result from the system that makes the decision instead of extrapolating from this probe. A canonical name recorded in a fixture and a production verdict are different kinds of evidence, and the gap between them is where overclaiming happens.

Practical conclusion

Record the ICU and tzdata versions, the input name, the sampled instant, the call shape, and the locale with its hour cycle. In this build the older spellings win canonicalization across four buckets, Asia/Bombay is rejected outright, five UTC-style inputs collapse to UTC, TZ sets a default that an explicit option overrides without changing, offsets include 45-minute values, and a fixed offset matches a DST zone in January and diverges in July.

Sources

Next reading

Use timezone, locale and language for how the three fields interact, and navigator language and application localization for the language side of the same profile.

#ICU#Timezone#Intl#Fingerprinting#Browser Identity

This article is part of the guide: What Is a Browser Fingerprint?

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.