Playwright architecture, selector reliability, and advanced interaction patterns.

Configuring Retries and Timeouts for Stable CI

Timeouts and retries are the safety valves that keep a suite green on slow, contended CI hardware without papering over real bugs. Set them too low and healthy tests fail when a machine is busy; set them too high and a genuinely broken test wastes minutes before it gives up. Playwright exposes several distinct timeouts — the per-test timeout, the per-assertion expect.timeout, and the action and navigation timeouts — plus a retries count, all configurable globally in playwright.config.ts and overridable per test. This page explains what each one bounds and how to tune them, and it belongs to Flaky Test Management within the Debugging & Test Observability guide.

How Playwright timeouts nest inside the test timeout A nested set of boxes showing the test timeout containing action, navigation, and expect timeouts, with retries wrapping the whole test. timeout (whole test) actionTimeout click, fill navigationTimeout goto, waitForURL expect.timeout each web-first assertion retries rerun on failure
The per-test timeout bounds the whole run; action, navigation, and expect timeouts bound individual steps inside it; retries wrap the entire test and rerun it on failure.

Root cause: one budget, four independent clocks

Almost every "random CI timeout" traces back to the same misunderstanding — engineers treat Playwright's timeouts as a single dial when they are four separate clocks with different scopes, only one of which is cumulative. The per-test timeout is the only budget that accumulates across steps; the others each restart from zero on every individual action, navigation, or assertion. A test can therefore blow its overall budget while no single step ever came close to its own limit, and conversely a single stalled assertion can fail a test that has 25 seconds of budget left.

The second half of the problem is that a retry is not a tuning knob for slowness. Retries exist to absorb genuine non-determinism — a container that lost its network for 200ms, a backend that occasionally returns a cold-cache response — not to give a slow test a second, longer run. Every attempt gets the same budget, so a test that is simply too slow for its timeout will fail identically three times and cost you three times the wall clock.

The settings are not interchangeable; each caps a different scope:

Leaving actionTimeout and navigationTimeout unset is the most common configuration mistake, because it turns any hung step into a full-budget stall whose error message points at the test rather than the step.

Minimal reproducible example

The test below looks healthy and passes on a developer laptop, but fails on CI with an error that blames the wrong thing. The backend recomputes invoice totals asynchronously, and on loaded CI hardware that work takes longer than the default assertion budget:

import { test, expect } from '@playwright/test';

test('dashboard shows the recalculated invoice total', async ({ page }) => {
  // No explicit budget here, so the test inherits `timeout` from the config.
  await page.goto('/dashboard');
  // Actionability checks run before the click and count against actionTimeout.
  await page.getByRole('button', { name: 'Recalculate' }).click();
  // The backend takes ~8s under CI load, but expect.timeout defaults to 5000ms,
  // so this assertion gives up long before the value has a chance to arrive.
  await expect(page.getByTestId('invoice-total')).toHaveText('$1,240.00');
  // Everything after this line never runs on CI, which makes the test look
  // like a locator problem when it is purely a budget problem.
  await expect(page.getByRole('status')).toContainText('Up to date');
});

The failure reads Timed out 5000ms waiting for expect(locator).toHaveText(), and the instinct is to change the locator or add a waitForTimeout(). Both are wrong. The locator is correct and the element does eventually appear — the assertion simply was not given enough polling time. Converting hard waits into properly budgeted web-first assertions is the core habit described in Detecting and Fixing Flaky Playwright Tests.

The global configuration

Set the defaults once in playwright.config.ts. The retries and timeout values commonly differ between local development and CI, so key them off the CI environment variable:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Retry failed tests twice on CI, never locally so flakes are obvious.
  retries: process.env.CI ? 2 : 0,
  // Whole-test budget; raise it for slow CI hardware.
  timeout: 30_000,
  expect: {
    // Each web-first assertion polls up to this long before failing.
    timeout: 7_000,
  },
  use: {
    // Cap on a single action such as click() or fill().
    actionTimeout: 10_000,
    // Cap on goto() and other navigations.
    navigationTimeout: 15_000,
    // Record a trace only on the first retry to keep artifacts small.
    trace: 'on-first-retry',
  },
});

These global values live alongside fixtures and projects in Playwright Config & Fixtures, the home for all suite-wide setup. Two related budgets sit outside this block and are worth knowing: globalTimeout caps the entire run so a wedged pipeline cannot burn an hour of runner time, and fixture setup counts against the test timeout unless the fixture is worker-scoped, as covered in Worker-Scoped Fixtures for Expensive Setup.

What a retry actually resets

A retry is not a resumption. Playwright discards the failed attempt entirely, tears down every test-scoped fixture, and runs the test again from scratch in a fresh browser context with a clean cookie jar, empty storage, and a new page. That isolation is what makes retries safe: a half-completed attempt cannot leak state into the next one. It also means a retry is expensive — you pay the full setup cost again — and that any state your test created server-side (an order, a user record) still exists on the second attempt, which is the usual reason a test fails on retry with a duplicate-key error even though attempt one merely timed out.

Timeline of a test that fails once and passes on retry A horizontal timeline showing a first attempt exhausting its budget, a teardown and fresh context in between, and a second attempt passing well inside the same budget. retries: 2 on CI means up to three attempts, each with the same timeout Attempt 1 exhausts its 30s budget teardown, fresh context Attempt 2 passes in 21s 0s 30s 33s 54s trace: on-first-retry records attempt 2 reported as flaky, not failed
A retried test starts over in a clean context rather than resuming, so the run costs the failed attempt plus a full second attempt and is reported as flaky once it finally passes.

Because the reporter marks a test that recovers on retry as flaky rather than passed, retries buy you a green pipeline and a permanent record of instability at the same time. Feed that record into a review process instead of ignoring it; the routing options are set out in Quarantining Flaky Tests Without Blocking CI.

Per-test overrides

Most tests should inherit the global values; override only the genuine outliers. A test that uploads a large file or waits on a slow report generation can extend its own budget without inflating the global default for everyone:

import { test, expect } from '@playwright/test';

test('generates a large export', async ({ page }) => {
  // Triple the budget for this one slow test only.
  test.setTimeout(90_000);
  await page.goto('/reports');
  await page.getByRole('button', { name: 'Export all' }).click();
  // Override the assertion timeout inline for the long-running step.
  await expect(page.getByText('Export ready')).toBeVisible({ timeout: 60_000 });
});

test.describe('slow upload suite', () => {
  // Apply a timeout to every test in this group at once.
  test.describe.configure({ timeout: 120_000 });

  test('uploads a 1GB archive', async ({ page }) => {
    await page.goto('/upload');
    await page.setInputFiles('input[type=file]', 'fixtures/big.zip');
    await expect(page.getByText('Upload complete')).toBeVisible();
  });
});

test.setTimeout() replaces the budget outright, while test.slow() multiplies the configured value by three — prefer test.slow() when you want the override to track future changes to the global default rather than pinning an absolute number.

Step-by-step setup

  1. Set retries to zero locally and two on CI. Use retries: process.env.CI ? 2 : 0 so flakes surface loudly during development but a single slow CI machine does not redden the whole build.
  2. Keep the test timeout generous but finite. Leave timeout near the 30000ms default, raising it only if your slowest legitimate test genuinely needs more; an oversized global timeout makes broken tests hang for minutes and multiplies by the retry count.
  3. Tune expect.timeout to your app's latency. Set expect.timeout to a value that comfortably covers normal data load — 7000ms is a reasonable CI default — so web-first assertions wait long enough without masking real stalls.
  4. Bound actions and navigations explicitly. Set actionTimeout and navigationTimeout in the use block so a hung click or a never-resolving navigation fails fast with an error naming the step, instead of consuming the entire test budget.
  5. Override per test, never globally, for outliers. Use test.setTimeout() or test.describe.configure({ timeout }) for the few genuinely slow tests, leaving the global defaults tight for everything else.
  6. Record a trace on the first retry. Set trace: 'on-first-retry' so any test that needed a rerun leaves a diagnosable artifact while passing tests stay artifact-free, and pair it with screenshot: 'only-on-failure' as described in Capturing Screenshots and Video on Failure.

Troubleshooting variants

Match the symptom to the clock before you change a number. The mapping below covers the failures that actually show up in CI logs.

Which timeout to change for each CI symptom A three-column matrix mapping five common CI failure symptoms to the Playwright setting that governs them and a typical value. Symptom in the CI log Setting that governs it Typical value One assertion times out expect.timeout 7000 ms Test runs out of budget timeout 30000 ms A click never settles actionTimeout 10000 ms goto never resolves navigationTimeout 15000 ms Green only on second run retries plus trace 2 on CI
Read the failing step out of the CI log first, then change only the clock that governs it — raising the wrong value slows the suite without fixing anything.

The suite is green only because retries hide a real failure

If the flaky count in your report keeps climbing while the pass rate stays at 100%, retries are masking a defect rather than absorbing infrastructure noise. Treat the flaky list as a bug backlog: pull the trace from the first attempt, look for a race between a network response and an assertion, and fix the synchronization rather than raising the retry count. Guidance on choosing between waiting for a request and waiting for element state is in Waiting for Network Idle vs Element State.

Raising the timeout made CI slower without fixing failures

A larger timeout only helps when the work genuinely completes — just later. When the underlying failure is a request that never returns or an element that never renders, a bigger budget converts a fast red into a slow red and multiplies the cost by the retry count. Before you raise anything, check whether the failing step is one that would ever succeed; if it would not, tighten actionTimeout instead so the failure surfaces in seconds and the pipeline stays fast.

Test timeout exceeded but every individual step looks quick

This is the cumulative-budget case. Add --reporter=list or open the trace and sum the step durations — a test with thirty small assertions and two navigations can drift past 30 seconds without any single step exceeding its own limit. Split the test into smaller ones, or move shared setup into a worker-scoped fixture or a storageState file so the per-test budget is spent on the behaviour under test rather than on logging in again. Fixture setup time counts against the test budget, which is why heavyweight beforeEach blocks are a frequent cause of this failure.

Verification

Confirm the configuration holds four ways. First, run the suite on CI and check the report: tests that recover on a retry are marked flaky, proving retries is active. Second, deliberately break an assertion and time the failure — it should give up at roughly your expect.timeout, not the full test timeout. Third, open a trace from a retried run in the Trace Viewer & Debugging guide to confirm trace: 'on-first-retry' captured it, and read the step durations to see where the budget actually went. Fourth, run the suite on a deliberately contended machine — for example with the sharding setup in Running Playwright Tests in GitHub Actions with Sharding — and confirm the flaky count stays near zero. Wire these settings into the pipeline described in CI/CD Integration so every machine runs with identical limits.

Frequently Asked Questions

What is the difference between timeout and expect.timeout?

timeout is the budget for the entire test across all its steps, defaulting to 30000ms, while expect.timeout bounds how long a single web-first assertion polls before failing, defaulting to 5000ms. A test can exhaust its overall timeout even when no individual assertion hits its own limit, because the limits are summed across every step.

Should I set retries above zero on my local machine?

No. Local retries hide flakiness from the engineer most able to fix it, so keep retries: 0 during development and enable retries only on CI. The common pattern is retries: process.env.CI ? 2 : 0, which protects the shared build while keeping local feedback honest.

How do I give one slow test a longer timeout?

Call test.setTimeout(ms) at the top of that test, or wrap a group with test.describe.configure({ timeout: ms }), instead of raising the global default. You can also pass { timeout: ms } to an individual assertion when only one step is slow, leaving the rest of the suite tight.

Does a retry reuse the browser context from the failed attempt?

No. Playwright tears down every test-scoped fixture and starts the retry in a completely fresh context with empty cookies and storage, so nothing leaks from the failed attempt. Anything the test wrote to a real backend does survive, which is why retried tests sometimes fail on duplicate data rather than on the original error.

How many retries should a CI pipeline allow?

Two is the practical ceiling for most suites. One retry absorbs single-event noise, a second covers the rare double hit, and beyond that you are paying significant wall-clock time to hide a defect you should be fixing. If two retries are not enough to keep the pipeline green, the problem is the test or the environment, not the retry count.

Back to overview