Flaky Test Mitigation

A flaky test is one that passes and fails against identical code, and left unmanaged it corrodes the entire signal your suite produces. Once engineers learn that a red build might just be noise, they start re-running pipelines reflexively, merging through failures, and eventually ignoring the test results altogether — the exact opposite of what a test pyramid strategy is supposed to deliver. This guide treats nondeterminism as an engineering defect with a defined lifecycle: detect it, contain it without hiding real regressions, fix the root cause, and only then return the test to the trusted lane. The techniques here apply across the JavaScript stack, but the worked examples use Vitest as the primary runner and Playwright for browser-level checks, with concrete knobs for retries, quarantine annotations, and seeded fixtures that you can adopt incrementally.

Flaky test lifecycle and quarantine states A state diagram showing a test moving from Trusted to Suspect on a retry-rescued failure, into Quarantine, then either back to Trusted after a clean fix and fifty green runs, or to Deleted when no owner fixes it by the deadline. Trusted merge gate Suspect passed on retry Quarantine non-blocking lane Deleted no owner, deadline hit flaky failure tag + isolate fixed, 50 green deadline missed
The flaky-test lifecycle: every unstable test is either promoted back to the trusted lane on evidence or deliberately retired.

Architectural Scope & Boundaries

Mitigation work sits at the seam between test authoring and CI orchestration, and it touches all three tiers of the pyramid differently. Unit tests are rarely flaky for environmental reasons; when they are, the cause is almost always shared mutable state or unseeded randomness, which is fixable at the source. Integration tests flake on timing, ordering, and leaked module state. End-to-end and browser tests flake on real network latency, animation timing, and resource contention under parallel load. The strategies in this section are deliberately tiered to match those causes.

Matching the fix to the tier matters because the same symptom — an intermittent red — has a different root cause and a different economical remedy at each level. A unit flake is almost never worth quarantining, because seeding or resetting shared state fixes it outright in minutes; paying the overhead of a separate lane for it would be wasteful. An end-to-end flake, by contrast, often reflects genuine timing or contention that takes real investigation, and containment buys the team the time to do that investigation without holding the merge queue hostage. Reading a flake by its tier tells you immediately whether to reach for a source fix, a retry budget, or the quarantine lane, and prevents the common mistake of applying a heavyweight CI mechanism to a problem a one-line seed would have solved.

Flake causes and remedies by pyramid tier Three stacked tiers — unit, integration, and end-to-end — each paired with its dominant flake cause and the cheapest effective remedy, from source-level seeding at the unit tier to retry budgets and quarantine at the browser tier. Tier Dominant flake cause Cheapest remedy Unit bulk of assertions unseeded randomness, shared mutable state seed + reset at the source Integration module collaboration ordering, leaked module state, timers isolate + fake timers per file End-to-end few, high-value flows network latency, animation, contention retry budget + quarantine lane Cost of the flake — and of its fix — climbs as you move down the stack.
Each tier flakes for a characteristic reason, so the cheapest effective remedy differs by layer.

This material covers four things and explicitly excludes a fifth. It covers: classifying a failure as a genuine regression versus nondeterminism; bounding retries so they buy stability without masking bugs; isolating unstable specs into a separate lane with measurable exit criteria; and removing the most common source of nondeterminism — unseeded data and uncontrolled time. It does not cover writing the underlying assertions or component harnesses; for that, see Playwright component testing and the broader Component & Integration Testing work. Mitigation assumes the test is correct in intent and only its determinism is in question.

The boundary that matters most is between containment and concealment. A retry that silently turns a real intermittent bug green is concealment; a retry that surfaces the flake in a report while keeping the merge queue moving is containment. Every knob in this section is chosen to stay on the containment side of that line. The practical test for any mitigation you introduce is a single question: after this change, is a genuine one-in-fifty product defect more visible or less visible? A quarantine lane that dashboards a growing flake population makes the defect more visible; a bumped retry count that closes the investigation makes it less. The two can look identical on a build-status badge — both show green — which is precisely why the discipline has to be judged by the data it preserves rather than by the colour of the pipeline.

A second boundary worth naming is between individual flakiness and systemic flakiness. A single racy assertion is an authoring bug you fix in place. But when a whole suite’s flake rate rises after a parallelism change or an infrastructure migration, no amount of per-test seeding will help, because the cause is contention or resource exhaustion that lives outside any one spec. Mitigation therefore operates at two scopes at once: it gives authors the tools to kill individual flakes at the source, and it gives platform owners a lane and a trend line to manage the systemic residue that individual fixes cannot reach.

Prerequisites

Step-by-Step Implementation

The lifecycle below moves a suspect test from detection through to either a clean fix or a justified removal. Each step has a focused, runnable configuration.

Step 1 — Make flakes observable before you react to them. You cannot manage what you cannot count. Configure Playwright to retain a trace on the first retry so every flake produces a forensic artifact rather than a vanished failure.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  reporter: [['list'], ['json', { outputFile: 'results.json' }]],
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});

Step 2 — Bound retries with a deliberate budget, not an open door. Two retries is a common ceiling: it absorbs genuine one-in-a-thousand environmental blips while keeping a persistently failing test visibly red. Set retries to 0 locally so authors feel their own flakes immediately.

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // Vitest retries individual tests; keep it low and CI-only.
    retry: process.env.CI ? 2 : 0,
    reporters: ['default', 'json'],
    outputFile: { json: './vitest-results.json' },
  },
});

Step 3 — Tag the unstable specs. Annotate suspect tests so tooling can route them. Playwright supports tags directly in the title; Vitest uses a custom annotation convention you can filter on.

// example.spec.ts (Playwright)
import { test, expect } from '@playwright/test';

test('checkout completes @flaky', async ({ page }) => {
  await page.goto('/checkout');
  await expect(page.getByRole('status')).toHaveText('Order placed');
});
// example.test.ts (Vitest) — name-based tagging filtered in CI
import { test, expect } from 'vitest';

test('[quarantine] settles async price calc', async () => {
  expect(await computePrice()).toBe(4200);
});

Step 4 — Route tagged tests into a non-blocking lane. The main job excludes the quarantine tag and stays a hard merge gate; a second, non-blocking job runs only the tagged tests and reports trends without breaking the build.

# .github/workflows/test.yml (excerpt)
jobs:
  trusted:
    runs-on: ubuntu-latest
    steps:
      - run: npx playwright test --grep-invert @flaky
  quarantine:
    runs-on: ubuntu-latest
    continue-on-error: true   # informational, never blocks merge
    steps:
      - run: npx playwright test --grep @flaky

Step 5 — Attack the root cause with determinism. Most flakes that survive into quarantine are data- or time-driven. Freeze the clock and seed every random source so a fixture that fails on Tuesday at 23:59 UTC also fails on your laptop at noon.

// vitest.setup.ts
import { beforeEach, afterEach, vi } from 'vitest';
import { faker } from '@faker-js/faker';

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-06-21T12:00:00Z'));
  faker.seed(20260621); // identical synthetic data every run
});

afterEach(() => {
  vi.useRealTimers();
});

Step 6 — Promote a test back to the trusted lane on evidence, not hope. Once a fix lands, the test must demonstrate stability — for example, 50 consecutive green runs in the quarantine lane — before its tag is removed. This exit criterion is what separates mitigation from sweeping the problem under the rug.

Configuration Reference Table

Knob Tool Type Default Effect
retries Playwright number 0 Re-runs a failed test up to N times; a test that passes on retry is reported as “flaky”, not “passed”.
retry Vitest number 0 Re-runs a failing test up to N times before marking it failed.
trace Playwright string 'off' 'on-first-retry' captures a full trace only when a test flakes, keeping artifacts cheap.
--grep / --grep-invert Playwright regex none Includes or excludes tests by title tag; the basis for the quarantine lane.
continue-on-error CI job boolean false Lets the quarantine job report without blocking the merge.
faker.seed(n) faker number random Pins the PRNG so generated fixtures are byte-identical across runs.
vi.setSystemTime(date) Vitest Date system clock Freezes Date.now() and timers for time-dependent assertions.
maxFailures Playwright number 0 Bails the run early after N failures to shorten feedback on broken builds.
fullyParallel Playwright boolean false Higher parallelism increases contention-driven flakes; tune per-suite.
flake budget policy percent team-set The retry-success rate above which a test is auto-quarantined.

Verification & Assertions

Confirm the machinery works before trusting it. After enabling retries with tracing, force a known intermittent failure and check that the report distinguishes a flaky outcome from a clean pass. Playwright’s summary will read something like 1 flaky rather than 1 passed, and a trace.zip will appear under test-results/. That distinction is the whole point: a green build with zero flaky entries is trustworthy, while a green build with a rising flaky count is a warning you can act on.

For seeded determinism, assert reproducibility directly. Generate a fixture twice within the same seeded context and assert deep equality; then run the file in isolation versus inside the full suite and confirm identical output. A divergence proves state is leaking across files — the signature failure mode that quarantining alone would only hide.

import { test, expect, beforeEach } from 'vitest';
import { faker } from '@faker-js/faker';

beforeEach(() => faker.seed(42));

test('seeded fixture is reproducible', () => {
  const a = faker.person.fullName();
  faker.seed(42);
  const b = faker.person.fullName();
  expect(a).toBe(b);
});

The quarantine lane is verified by inspecting CI: the trusted job must turn red on a real regression while the quarantine job stays informational. Open a PR that breaks a quarantined test and confirm the merge button remains enabled; break a trusted test and confirm it blocks. This pair of deliberately broken pull requests is the contract that makes the whole system safe, and it is worth re-running whenever branch-protection rules change, because a single mislisted required check can silently promote the quarantine job back into a merge gate and undo the decoupling the lane was built to provide.

Verification should also cover the trend, not just a single run. A green build with zero flaky entries proves nothing about direction; only a series of builds does. Run the trend script across several consecutive CI runs and confirm the recorded flake count is flat or falling — a rising line, even under an all-green pipeline, is the earliest signal that a systemic flake source has appeared and that source-level fixes are not keeping pace with new instability. Treat that number as a first-class health metric alongside pass rate and suite duration, and review it on the same cadence you review coverage, so mitigation stays a measured programme rather than a reactive scramble each time a build goes red.

Edge Cases & Failure Modes

Retries that mask a real intermittent bug. If a feature genuinely fails one request in fifty, retries will paper over it and ship the defect. Guard against this by treating a rising flaky rate as a regression signal in its own right — track the count, alert on growth, and never let “it passed on retry two” close an investigation. The companion guide on retrying flaky Playwright tests without masking bugs covers the trace-driven triage that keeps retries honest.

The single distinction that separates a healthy mitigation programme from a dangerous one is whether a retry-passed test is collapsed into a green tally or kept as a distinct, tracked outcome. The runner already draws this line for you — Vitest and Playwright both report a flaky status that is neither passed nor failed — but a pipeline that discards that status the moment the build goes green throws away the only early warning it had. The diagram below contrasts the two paths a retry-rescued failure can take.

Concealment versus containment for a retry-rescued failure A retry-rescued failure either collapses into a green pass and lets a real bug ship, or is kept as a distinct flaky outcome whose rising count is tracked and triaged. Passed on retry failed once, then green Concealment path collapsed into "passed" real bug ships unnoticed signal destroyed Containment path kept distinct as "flaky" rising count triaged signal preserved
Every retry-rescued failure travels one of two paths; the whole discipline is about staying on the containment side.

Quarantine becoming a graveyard. Tests dumped into the quarantine lane with no exit criteria accumulate forever, and coverage silently rots. Every quarantined test needs an owner and a deadline; if neither materializes, deleting the test is more honest than pretending it guards anything.

Order-dependent failures that seeding cannot fix. Seeding randomness and freezing time will not save a test that depends on another test having run first. Detect these by shuffling execution order (--sequence.shuffle in Vitest) and isolating the failures; the fix is proper teardown, not retries.

Shared singletons across parallel workers. Module-level caches, a single MSW server, or a shared database connection will produce contention flakes that scale with fullyParallel. Scope state to the worker or reset it per-file, mirroring the reset discipline used in external service simulation. The tell-tale signature is a test that passes in isolation and at low concurrency but fails intermittently as worker count rises: the failure rate tracks parallelism, not code, which points squarely at shared state rather than a logic bug. Reproduce it deliberately by pinning the worker count high locally before you attempt a fix, so you can confirm the fix under the same contention that produced the flake.

Environment-coupled flakes that only appear in CI. Some tests are stable on a developer laptop and flake only on the CI runner because the runner is slower, more contended, or configured with a different timezone or locale. These resist local reproduction entirely, which makes the retained trace and the frozen-clock discipline non-negotiable — without a forensic artifact from the actual failing environment you are debugging blind. Pin process.env.TZ and locale in the runner config, capture a trace on first retry, and treat any “works on my machine” flake as evidence that an ambient input is leaking in rather than as proof the test is fine.

Performance & CI Impact

Retries trade wall-clock time for stability, and the trade is asymmetric: a two-retry ceiling adds latency only to tests that actually fail, so a healthy suite pays almost nothing while a sick one pays loudly — which is the correct incentive. Tracing on-first-retry keeps artifact storage proportional to flake volume rather than total test count, avoiding the gigabytes that trace: 'on' would generate.

The quarantine lane’s biggest performance win is psychological and structural: by removing unstable tests from the merge gate, you stop the cascade of full-pipeline re-runs that flakes provoke, which is often the single largest source of wasted CI minutes. Deterministic seeding has near-zero runtime cost and frequently reduces it by eliminating the retry rounds those flakes would have triggered. When you measure the impact, fold it into the same ledger you use for balancing speed and coverage in monorepo testing so flake-mitigation spend is weighed against the feedback-loop time it buys back.

The hidden cost most teams miss is not machine time but human time. A flaky suite taxes every engineer who has to decide whether a red build is real, re-run it, wait, and re-decide — a cost that compounds across the whole team on every pull request and never appears on a CI invoice. Quantify it by tracking two numbers over time: the median wall-clock time from a red build to a confident triage decision, which should fall once flakes are contained and the trend line is visible, and the ratio of retried-then-passed runs to clean first-pass runs, which is a direct proxy for how much noise the team is absorbing. When those two numbers move in the right direction, the mitigation programme is paying for itself even before you count the CI minutes saved. Budget the effort accordingly: the first hour spent seeding a data-driven flake at the unit tier typically returns far more than the same hour spent tuning retry counts at the browser tier, because it removes the flake permanently instead of merely containing it, and permanent removal is the only change that also shrinks the human decision tax.

In-Depth Guides