Article · Root Causes of JavaScript Test Flakiness

Fixing Timezone and Locale Dependent Test Failures

A suite that goes red every evening between 22:00 and midnight, or only on the last day of the month, is reading the runner's clock configuration rather than the application's behaviour. This guide narrows CI Environment & Browser Drift to the date and formatting axis: why a timestamp renders on two different days depending on where the process runs, why Node and the browser can disagree inside a single test, and which assertion shapes survive both.

12 sections URL: /root-causes-of-javascript-test-flakiness/ci-environment-and-browser-drift/fixing-timezone-and-locale-dependent-failures/
Two clocks in one test The Node process reads the TZ environment variable while the browser context reads its own timezoneId; setting only one leaves them disagreeing. Node test process reads TZ env var builds fixtures, seeds the API UTC browser context reads timezoneId option renders what the user sees host zone (unset) 2 h a fixture created "today" in one clock can render as "yesterday" in the other
Setting the browser zone but not the container zone leaves two clocks in the same test, offset by whatever the host happens to be.

Root cause #

Date in JavaScript stores an absolute instant but formats it in the ambient zone. new Date('2026-08-02T23:30:00+02:00').toDateString() yields 2 August in Berlin and 2 August in UTC — but shift the instant half an hour later and the two answers land on different dates. Any assertion that compares a rendered day, month label or “3 days ago” string is therefore a function of the runner’s zone, and it will pass all day and fail near the boundary.

Locale drives a second family of failures that look nothing like clock bugs. Number formatting differs by separator (1,234.5 versus 1.234,5), dates by field order (02/08/2026 versus 08/02/2026), and string comparison by collation, so a sorted-list assertion can be correct in en-GB and wrong under the C locale that many minimal containers default to. Non-breaking and narrow no-break spaces are the sharpest edge here: Intl.NumberFormat('fr-FR').format(1234) separates the thousands with U+202F, which is not the ASCII space in the test’s expected string, so a comparison that looks identical on screen fails on the byte.

Both problems have the same shape — an implicit read of ambient configuration — and the same fix: make the configuration explicit at both layers, then assert on values rather than on formatted output wherever the format is not itself the subject.

Step-by-step fix #

1. Set the zone on both layers #

The browser option and the process environment are independent. Set both, in the config and in the container.

// playwright.config.ts
// Trade-off: pinning to UTC makes results identical everywhere, and means the
// suite no longer exercises the zone conversion your users actually hit.
export default defineConfig({
  use: { timezoneId: 'UTC', locale: 'en-GB' },
});
# .github/workflows/e2e.yml — the Node side of the same setting
env:
  TZ: UTC          # affects fixtures, seed scripts and any server started in CI
  LANG: en_GB.UTF-8

Cypress reads the host zone for the browser it launches, so the container TZ is the control point there; setting it in the workflow covers both the Node and browser sides in one place.

2. Freeze the clock for anything relative #

Relative rendering — “2 minutes ago”, “expires tomorrow” — is a function of now, and now moves during the run. Install a fixed clock instead of computing expectations from the current time.

// Trade-off: a frozen clock makes relative output deterministic, but any code
// polling for change will now never advance — step the clock explicitly instead.
test('renders a relative timestamp', async ({ page }) => {
  await page.clock.install({ time: new Date('2026-08-02T12:00:00Z') });
  await page.goto('/inbox');
  await expect(page.getByTestId('received')).toHaveText('2 hours ago');
});

The same technique in Cypress and Jest, including the pitfalls of pausing a clock that the application depends on advancing, is covered in Faking Timers with Jest and cy.clock.

Computed expectation versus frozen clock Deriving the expected string from the current time re-implements the formatter; freezing the clock lets the expectation be a literal. expected = format(Date.now())test re-implements the app passes even when both are wrong the same wayand fails when the second ticks between the two calls clock frozen, literal expected'2 hours ago' asserts the actual rendered contract, deterministically
An expectation computed with the same formatter the app uses cannot detect a formatting bug — it only detects disagreement about the clock.

3. Assert on values, not on formatted strings #

Where the format is not the thing under test, compare the underlying instant or number. A datetime attribute or a data attribute gives the test an unambiguous value while leaving the visible text free to be localised.

// Trade-off: asserting the machine-readable value is robust across locales but
// stops catching formatting regressions — keep one explicit format test per locale.
await expect(page.getByTestId('due')).toHaveAttribute(
  'datetime', '2026-08-02T12:00:00.000Z'
);

// And exactly one test that does assert the rendered format, per supported locale:
test('formats currency for fr-FR', async ({ page }) => {
  const text = await page.getByTestId('total').innerText();
  // Normalise the narrow no-break space Intl emits before comparing.
  expect(text.replace(/[  ]/g, ' ')).toBe('1 234,50 €');
});

4. Add a second zone as a real project #

Pinning to UTC removes the flakiness and also removes the coverage. Restore the coverage deliberately with a project that runs a subset of specs in a zone with a non-zero offset and daylight-saving transitions.

// Trade-off: a second project adds CI minutes; scope it to the date-sensitive
// specs with a grep rather than running the whole suite twice.
projects: [
  { name: 'utc', use: { timezoneId: 'UTC' } },
  {
    name: 'berlin',
    use: { timezoneId: 'Europe/Berlin', locale: 'de-DE' },
    grep: /@zone/,     // tag the specs that care
  },
],

5. Seed the boundaries rather than waiting for them #

The failures live at day, month and year boundaries and at daylight-saving transitions, which a suite running at 10:00 on a Tuesday will never reach. Create those instants deliberately as fixtures so the edge case is a permanent test rather than an occasional nightly surprise.

// Trade-off: four extra fixtures cost a few seconds and convert an
// intermittent evening failure into a case that fails for a stated reason.
const BOUNDARIES = {
  dayEdge:   '2026-08-02T23:30:00+02:00',  // yesterday in UTC
  monthEdge: '2026-08-31T23:00:00+02:00',  // next month locally
  leapDay:   '2028-02-29T12:00:00Z',       // date arithmetic edge
  dstJump:   '2026-03-29T01:30:00Z',       // a 23-hour day in Europe/Berlin
};

for (const [name, iso] of Object.entries(BOUNDARIES)) {
  test(`renders correctly at ${name} @zone`, async ({ page }) => {
    await page.clock.install({ time: new Date(iso) });
    await page.goto('/schedule');
    await expect(page.getByTestId('day-heading')).toBeVisible();
  });
}

6. Keep date logic out of the assertion entirely where you can #

The most robust pattern is to give the test no date arithmetic at all. Have the application expose the machine-readable instant it rendered from, seed fixtures with absolute instants rather than offsets from now, and let the test compare two absolute values. Every relative expression — “yesterday”, “next week”, “in an hour” — is a small re-implementation of the application’s own logic, and every re-implementation is a place where the test and the app can drift apart while both remain internally consistent.

Where relative rendering genuinely is the subject, freeze the clock and assert the literal string. Where it is not, assert on the datetime attribute and let the visible text be whatever the locale produces.

Pitfalls #

  • Setting timezoneId but not the container TZ. Node-side fixtures and the browser disagree inside one test. Mitigation: set both, in config and in the workflow environment.
  • Comparing formatted output built with the same formatter. The assertion mirrors the implementation and verifies nothing. Mitigation: compare against literals with the clock frozen.
  • Expecting ASCII spaces from Intl. French and several other locales use narrow no-break spaces. Mitigation: normalise whitespace before comparing, or assert on the numeric value.
  • Relying on the default locale in a minimal image. A container defaulting to C sorts and formats differently from a developer machine. Mitigation: set LANG explicitly and pass locale to the browser context.
  • Freezing the clock for the whole spec. Polling, animations and token refresh stop advancing and the app appears hung. Mitigation: freeze narrowly, then advance the clock explicitly where the app needs time to pass.
  • Testing only at midday. The boundary failures live near midnight and on month ends. Mitigation: seed fixtures at 23:30 and on the 31st on purpose.
Boundary cases worth seeding on purpose Midnight, month end, leap day and the daylight-saving transition are where zone-dependent assertions break. 23:30 localcrosses the day in UTC 31st at 23:00crosses the month 29 Febleap-day arithmetic DST switcha day of 23 or 25 hours four fixtures that turn an intermittent nightly failure into a permanent, deliberate test
Seeding the boundaries converts a flaky evening failure into a case that either passes or fails for a reason.

Reliability targets #

Metric Target Notes
Zone-dependent failures 0 per month Measured across a full 24-hour cycle of runs
Specs asserting formatted dates ≤ 1 per locale Everything else asserts machine-readable values
Clock-frozen relative-time tests 100% No expectation derived from Date.now()
Zones covered 2 (UTC + one offset zone) Second project scoped by tag
Boundary fixtures 4 (midnight, month end, leap, DST) Seeded explicitly
Time and locale scorecard Targets for zone-dependent failures, format assertions, frozen clocks and zone coverage. 0zone failures 1format test / locale 100%clocks frozen 2 zonescovered on purpose
UTC everywhere plus one deliberate offset zone gives determinism without giving up coverage.

Frequently Asked Questions #

Q: My assertion fails with what looks like an identical string. Why? A: Compare the code points. Intl inserts U+202F (narrow no-break space) or U+00A0 (no-break space) as a group and currency separator in many locales, and those are not the ASCII space in your expected literal. Normalise both sides with a replace over those code points before comparing, or assert the numeric value instead.

Q: Should the container time zone be UTC or the team’s local zone? A: UTC, because it is the one zone every runner can agree on and it has no daylight-saving transitions. Local-zone behaviour is then tested deliberately in a second project rather than accidentally by whoever runs the suite.

Q: Our API returns dates without an offset. Is that the real bug? A: Usually, yes. A timestamp such as 2026-08-02 23:30:00 with no zone is ambiguous, and every consumer resolves it differently — the browser as local time, a Node service as whatever TZ says. The test flakiness is a symptom of an under-specified contract. Fix it at the API with a full ISO 8601 instant including the offset, and the class of failure disappears from every client at once; the contract-checking approach in API Contract Validation in E2E Tests is how you keep it fixed.

Q: Does the locale affect anything other than formatting? A: It affects sorting. String comparison under Intl.Collator follows locale rules, so accented characters and case order differ between en-GB and, say, sv-SE. A test asserting the order of a name list can therefore be correct locally and wrong in a container defaulting to C. Pin the locale, and assert on the set of items where the exact order is not the behaviour under test.

Q: Does freezing the clock break authentication? A: It can. A frozen clock stops token expiry timers from advancing, and a session that refreshes on an interval will never refresh. Install the clock after login, or advance it explicitly with page.clock.fastForward() so the refresh still runs.