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

// 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

  1. Step 1.

    Read the zone the process is in, not the one the machine is in.

    node zone.mjs
    
    zone     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 carries Europe/Kyiv, so an equality assertion on the modern name fails on a correct machine. locale is uk-UA, read from the operating system, not from the zone.

  2. Step 2.

    Move the process to another zone and read it back. Set the variable in PowerShell.

    $env:TZ='America/New_York'; node zone.mjs
    
    zone     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. getTimezoneOffset flipped sign, because it reports the minutes to add to local time to reach UTC.

  3. Step 3.

    Render one stored instant in every zone that has users.

    node instant.mjs 2026-03-01T02:30:00Z
    
    instant 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:45

    One 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:30 and +13:45 are the other half of the output: arithmetic that assumes whole hours is already wrong for Kathmandu, Kolkata and the Chatham Islands.

  4. Step 4.

    Feed the system a stored offset instead of a zone name and see what the summer date does.

    node offsetnotzone.mjs
    
    2026-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 EDT

    Both strings say -05:00 and 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.

  5. Step 5.

    Parse the three string shapes your API might return, in a zone behind UTC.

    $env:TZ='America/Los_Angeles'; node parse.mjs
    
    process 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-07

    The 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

Sign: A test that sets the zone inline passes everywhere and proves nothing.Cause: In Git Bash on Windows, TZ=America/New_York node script.mjs leaves the process on the machine zone and reports no error. The output reads as an application that ignores TZ. Set it with $env:TZ in PowerShell, or from inside the runner config.
Sign: An assertion on the zone name fails on a machine that is configured correctly.Cause: Node resolved this machine to Europe/Kiev while tzdata 2026a also carries Europe/Kyiv. Zone ids have aliases, and which one comes back depends on the operating system entry, not on the data. Compare offsets or formatted output, not identifiers.
Sign: Dates are right for staff and one day out for customers on the other side of an ocean.Cause: The report range was built from the server date. Step 3 shows one instant landing on 2026-03-01 in Auckland and 2026-02-28 in Los Angeles, so a month boundary computed in UTC includes orders the customer places tomorrow and drops ones placed today.
Sign: A date-only field is correct in Europe and off by one in the Americas.Cause: new Date('2026-03-08') is parsed as UTC midnight, then displayed in local time, which is still 2026-03-07 in Los Angeles. The same string with a time and no offset is parsed as local instead. One character changes the rule.

What to check next

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.

basic6 minpublished updated Maks Verny