How to test timezone handling
Read the zone the process runs in with node -p "Intl.DateTimeFormat().resolvedOptions().timeZone", then render one stored instant in every zone your users live in. The same millisecond prints as 2026-03-01 in Auckland and 2026-02-28 in Los Angeles, which is the bug you are hunting.
Why check this
Run this before sign-off on anything that writes a timestamp and reads it back elsewhere: an order history, a report with a date range, a scheduled job, an audit log. The failure it prevents is a record that lands on the wrong calendar day for one group of users and on the right one for everybody in the office.
The scope is narrow. It tells you which zone the process is in, what one stored instant looks like from several zones, and whether the string your API returns survives parsing. It does not cover the database column, which is How to check timezone stored in database, or the two hours a year when a local clock is not a function of the instant, which is How to test daylight saving time.
Prerequisites
- Node 22 with full ICU.
node -p "Intl.supportedValuesOf('timeZone').length"printed418here; a small-ICU build answers with a handful. - PowerShell for the two steps that change the zone. In Git Bash,
TZ=America/New_York node script.mjsleaves the process on the machine zone and says nothing. - The Intl.DateTimeFormat reference for the option names below.
- Four scripts.
// zone.mjs
const r = Intl.DateTimeFormat().resolvedOptions();
console.log('zone ', r.timeZone);
console.log('locale ', r.locale);
console.log('offset ', new Date('2026-07-01T12:00:00Z').getTimezoneOffset(), 'minutes (getTimezoneOffset, sign inverted)');
console.log('sample ', new Date('2026-07-01T12:00:00Z').toString());
// instant.mjs
const iso = process.argv[2] ?? '2026-03-01T02:30:00Z';
const zones = ['UTC', 'Pacific/Auckland', 'Asia/Tokyo', 'Asia/Kathmandu', 'Asia/Kolkata',
'Europe/Kyiv', 'Europe/London', 'America/New_York', 'America/Los_Angeles', 'Pacific/Chatham'];
const d = new Date(iso);
console.log('instant', d.toISOString(), '=', d.getTime(), 'ms');
for (const zone of zones) {
const f = new Intl.DateTimeFormat('en-CA', {
timeZone: zone, hour12: false, dateStyle: 'short', timeStyle: 'long',
});
console.log(zone.padEnd(20), f.format(d));
}
// offsetnotzone.mjs
const f = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', hour12: false, dateStyle: 'short', timeStyle: 'long',
});
for (const s of ['2026-01-04T12:00:00-05:00', '2026-07-04T12:00:00-05:00']) {
const d = new Date(s);
console.log(s, '->', d.toISOString(), '->', f.format(d));
}
// parse.mjs
console.log('process zone ', Intl.DateTimeFormat().resolvedOptions().timeZone);
for (const s of ['2026-03-08', '2026-03-08T00:00:00', '2026-03-08T00:00:00Z']) {
const d = new Date(s);
console.log(
JSON.stringify(s).padEnd(24),
'UTC', d.toISOString(),
' local day', new Intl.DateTimeFormat('en-CA').format(d)
);
}
Steps
- Step 1.
Read the zone the process is in, not the one the machine is in.
node zone.mjszone Europe/Kiev locale uk-UA offset -180 minutes (getTimezoneOffset, sign inverted) sample Wed Jul 01 2026 15:00:00 GMT+0300 (за східноєвропейським літнім часом)Two details in four lines. The zone resolves to
Europe/Kiev, the deprecated alias, while the same tzdata also carriesEurope/Kyiv, so an equality assertion on the modern name fails on a correct machine.localeisuk-UA, read from the operating system, not from the zone. - Step 2.
Move the process to another zone and read it back. Set the variable in PowerShell.
$env:TZ='America/New_York'; node zone.mjszone America/New_York locale uk-UA offset 240 minutes (getTimezoneOffset, sign inverted) sample Wed Jul 01 2026 08:00:00 GMT-0400 (за північноамериканським східним літнім часом)The zone moved and the locale did not.
getTimezoneOffsetflipped sign, because it reports the minutes to add to local time to reach UTC. - Step 3.
Render one stored instant in every zone that has users.
node instant.mjs 2026-03-01T02:30:00Zinstant 2026-03-01T02:30:00.000Z = 1772332200000 ms UTC 2026-03-01, 02:30:00 UTC Pacific/Auckland 2026-03-01, 15:30:00 GMT+13 Asia/Tokyo 2026-03-01, 11:30:00 GMT+9 Asia/Kathmandu 2026-03-01, 08:15:00 GMT+5:45 Asia/Kolkata 2026-03-01, 08:00:00 GMT+5:30 Europe/Kyiv 2026-03-01, 04:30:00 GMT+2 Europe/London 2026-03-01, 02:30:00 GMT America/New_York 2026-02-28, 21:30:00 EST America/Los_Angeles 2026-02-28, 18:30:00 PST Pacific/Chatham 2026-03-01, 16:15:00 GMT+13:45One millisecond, two calendar days, and one of them is the last day of February. A monthly report cut on the UTC date and one cut on the customer's date hold different orders. The offsets
+5:45,+5:30and+13:45are the other half of the output: arithmetic that assumes whole hours is already wrong for Kathmandu, Kolkata and the Chatham Islands. - Step 4.
Feed the system a stored offset instead of a zone name and see what the summer date does.
node offsetnotzone.mjs2026-01-04T12:00:00-05:00 -> 2026-01-04T17:00:00.000Z -> 2026-01-04, 12:00:00 EST 2026-07-04T12:00:00-05:00 -> 2026-07-04T17:00:00.000Z -> 2026-07-04, 13:00:00 EDTBoth strings say
-05:00and both were meant to say "noon in New York". In July the answer is 13:00. An offset is a fact about one instant, a zone is a rule. Storing the first in place of the second moves every future event by an hour twice a year. - Step 5.
Parse the three string shapes your API might return, in a zone behind UTC.
$env:TZ='America/Los_Angeles'; node parse.mjsprocess zone America/Los_Angeles "2026-03-08" UTC 2026-03-08T00:00:00.000Z local day 2026-03-07 "2026-03-08T00:00:00" UTC 2026-03-08T08:00:00.000Z local day 2026-03-08 "2026-03-08T00:00:00Z" UTC 2026-03-08T00:00:00.000Z local day 2026-03-07The first two differ by eight hours after a one-character edit. ECMAScript parses a date-only form as UTC and a date-and-time form with no offset as local time, so a birthday field shows the day before for every user west of Greenwich.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| zone is UTC on the server and a city on your laptop | The defect hides in production and appears locally, or the reverse | Pin the zone in CI instead of inheriting it |
| Two zones show different calendar dates for one instant | Correct behaviour, and the reason a date range needs a zone | Cut report ranges in the user's zone, never in the process zone |
| An offset with :30 or :45 | Kolkata, Kathmandu, Chatham and others | Remove any code that divides an offset by 60 and keeps the integer |
| A stored value that carries -05:00 rather than a zone id | Future events drift by an hour at the next transition | Store the instant plus the IANA zone name |
| 2026-03-08 and 2026-03-08T00:00:00 land on different days | The two-rule parsing in the language specification | Decide which one the field means and make the API emit only that |
Common mistakes
What to check next
- How to test daylight saving time: the two hours a year when a local clock does not map to one instant.
- How to test date format in different locales: once the instant is right, the rendering is next.
- How to check timezone stored in database: whether the column keeps what the application sent.
- How to run jest tests with a specific timezone: pin the zone in a runner and in CI rather than by hand.
- How to change timezone in chrome for testing: the same check from the front end.
- Convert a timestamp across timezones: read one value in several zones without a script.
FAQ
How to check what timezone an application uses?
Ask the running process, not the host. Intl.DateTimeFormat().resolvedOptions().timeZone returns the zone that formatting and Date arithmetic use. A container inherits TZ from its own environment, so the answer can differ from the machine under it.
Should the server run in UTC?
Running in UTC removes one variable and hides another: defects that need the process zone to differ from the user zone stop reproducing locally. Pin UTC in production, then run the suite in one zone behind UTC and one ahead of it.
Is storing the UTC offset enough?
No. Step 4 shows -05:00 meaning noon in January and 13:00 in July for one city. An offset describes a single instant. For future events, store the instant and the IANA zone id.
Why is getTimezoneOffset negative for a zone ahead of UTC?
It returns the minutes to add to local time to reach UTC. Kyiv in summer returns -180, New York returns 240. The sign is the opposite of the one in an ISO 8601 offset.
How many zones should a test cover?
Four cover most defects: UTC, one zone ahead, one behind, and one with a non-hour offset such as Asia/Kolkata. Add Pacific/Chatham when the code does arithmetic on offsets.
Verified
Verified by Maks Vernynode 22.23.2ICU 78.2tzdata 2026a
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: timestamp convert between Unix time, ISO 8601 and locale formats across timezones
- Localization testing checklist
- All localization and formats checks
basic6 minpublished updated Maks Verny