breakhound StarGitHub stars

Guide

Timezone bugs in JavaScript and how to catch them

A JavaScript Date is one instant, but every getter answers in some time zone. Mix two of them and your code is right on your laptop and wrong for half the planet.

By Alexandre KohlerUpdated 4 min read

Why timezone bugs in JavaScript are so common

A Date stores a single number: milliseconds since 1970-01-01T00:00:00Z. Everything else is interpretation. getFullYear(), getMonth() and getHours() interpret that number in the host time zone, set by the TZ environment variable on Node or the operating system in a browser. getUTCFullYear() and friends interpret it in UTC. Intl.DateTimeFormat and toLocaleString use the host zone unless you pass timeZone.

That gives you at least three answers to the question "what year is this?", and they disagree for a few hours every day. Your laptop runs in one zone, your CI runner usually runs in UTC, and your users are everywhere. A test that passes locally and fails in CI, or the reverse, is the classic symptom.

Mixing UTC and local getters

The most common version of the bug is a comparison done in one zone and a display done in another. Here is a relative date label that shows "Mar 5" for dates in the current year and "Mar 5, 2024" otherwise:

label.ts
export function label(date: Date, now: Date): string {
  // bug: the year is compared in UTC, but the text is rendered in the host zone
  if (date.getUTCFullYear() === now.getUTCFullYear()) {
    return date.toLocaleDateString('en', { month: 'short', day: 'numeric' });
  }
  return date.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' });
}

It works all year. Then, on December 31 in New York, the evening has already become January 1 in UTC. The code decides the year is different and prints a year the viewer does not expect, or the reverse for a date on the other side of midnight. The bug only appears for a few hours per year in each zone, which is why tests and code review miss it.

This is the same bug class fixed in github/relative-time-element#384: the year check used UTC instead of the time zone the date was displayed in. The fix is to pick one zone per operation and use it for both the decision and the output:

label.ts
export function label(date: Date, now: Date, timeZone?: string): string {
  const year = (d: Date) =>
    Number(new Intl.DateTimeFormat('en', { year: 'numeric', timeZone }).format(d));
  const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', timeZone };
  if (year(date) !== year(now)) opts.year = 'numeric';
  return date.toLocaleDateString('en', opts);
}

Daylight saving time breaks "one day equals 24 hours"

Twice a year in most zones, a local day is 23 or 25 hours long. Code that adds days * 86400000 milliseconds lands an hour off on those days, and sometimes on the wrong calendar date:

addDays.ts
export function addDays(date: Date, days: number): Date {
  // bug: a local day is not always 24 hours
  return new Date(date.getTime() + days * 86400000);
}

If you mean "the same wall clock time, N calendar days later", use the calendar setters, which handle the transition for you:

addDays.ts
export function addDays(date: Date, days: number): Date {
  const next = new Date(date);
  next.setDate(next.getDate() + days);
  return next;
}

If you mean exactly 24 hours, keep the millisecond math but say so in the function name. The point is to make the choice on purpose. Spring forward and fall back are also where parsing goes wrong: a local time like 02:30 does not exist on one night and happens twice on another.

Dates before 1970 and negative timestamps

Timestamp zero is midnight UTC on January 1, 1970. Anything west of UTC sees that instant as December 31, 1969. That makes timestamp zero, and small negative timestamps, a perfect probe for zone-dependent code. Negative values also expose rounding mistakes, for example when someone uses Math.floor on one side and integer division on the other.

yearLabel.ts
export function yearLabel(ms: number): string {
  return String(new Date(ms).getFullYear());
}

This function is fine as long as everyone agrees it means "the year in the host zone". It is a bug when callers expect a stable answer for the same input, which is what a pure function with a number argument promises. Call it with 0 in UTC and you get 1970. Call it in America/Sao_Paulo and you get 1969. Either take a timeZone argument or use getUTCFullYear() and document it.

How breakhound catches timezone bugs

You do not have to remember which instants are dangerous. breakhound reads the TypeScript types of your package and generates inputs for every parameter, including date edge cases from the edgegoat package: time zone boundaries, DST transitions and dates before 1970. Each function runs in an isolated child process under several TZ values (six by default, including UTC, America/Sao_Paulo, Pacific/Kiritimati and America/New_York). If the result changes only because the host zone changed, it reports a timezone-dependent finding.

Terminal
npx breakhound ./my-lib --rules timezone-dependent
Terminal output
HIGH (1)
  [timezone-dependent] yearLabel returns different results in different time zones (confidence medium)
    TZ=UTC yearLabel(0)  -> '1970'
    TZ=America/Sao_Paulo yearLabel(0)  -> '1969'
    TZ=Pacific/Kiritimati yearLabel(0)  -> '1969'
    TZ=America/New_York yearLabel(0)  -> '1969'

Every finding comes with a minimal reproduction script and a ready-made test that sets TZ itself, so you can paste it into your suite:

yearLabel.test.ts
import { expect, test } from 'vitest';
import { yearLabel } from 'my-lib';

test("yearLabel does not depend on the host time zone", () => {
  const original = process.env.TZ;
  try {
    process.env.TZ = 'UTC';
    const expected = yearLabel(0);
    process.env.TZ = 'America/Sao_Paulo';
    expect(yearLabel(0)).toEqual(expected);
  } finally {
    if (original === undefined) delete process.env.TZ;
    else process.env.TZ = original;
  }
});

Runs are seeded (default seed 42), so the same command gives the same findings. Read how to find bugs automatically in JavaScript for the full workflow, and automated testing in GitHub Actions to run it on every pull request.

breakhound executes the package code it analyzes. Run it on code you trust, or inside a container.

A checklist that prevents the bug

  • Choose one zone per operation and use it for both comparison and display.
  • Pass timeZone explicitly to Intl.DateTimeFormat and toLocaleString when the zone matters.
  • Use calendar setters (setDate, setMonth) for calendar math, not millisecond constants.
  • Test with at least UTC, a zone west of UTC, a zone far east, and a zone with DST.
  • Include timestamp 0, negative timestamps and the last hours of December 31 in your fixtures.

Unicode edge cases cause a similar class of silent failures, covered in surrogate pair bugs.

FAQ

Why does my date test pass locally but fail in CI?

CI machines usually run in UTC while your laptop runs in your local zone. Any code that mixes UTC and local getters behaves differently between the two. Set TZ explicitly in the test and run it under more than one zone.

Which time zones should I test?

At least UTC, one zone west of UTC such as America/New_York, one far east such as Pacific/Kiritimati, and a zone with daylight saving time. breakhound uses six zones by default.

Should I use getUTC methods everywhere?

No. Use UTC for storage and comparisons between instants, and the viewer's zone for display. The bug is mixing the two inside one decision, not using either one.

Does setting process.env.TZ at runtime work in Node?

On modern Node versions, yes, assigning process.env.TZ updates the zone for later date calls. The generated tests rely on that, and restore the original value afterward.

Can breakhound check functions that take callbacks or Date objects?

Yes. It generates inputs from your .d.ts types, including dates and generated callbacks, then compares results across zones.