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.
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:
timeout— the budget for an entire test, default 30000ms. If the sum of every action, navigation, assertion, and fixture setup exceeds it, the test fails regardless of which step was slow.expect.timeout— how long a single web-first assertion (toBeVisible(),toHaveText(), …) polls before failing, default 5000ms. It resets for everyexpect()call.actionTimeout— the cap on a single action likeclick()orfill(), including the actionability checks Playwright runs before it. Default is unset, meaning the action can consume the whole testtimeout.navigationTimeout— the cap ongoto(),waitForURL(), and similar navigations, default unset and likewise falling back to the test budget.
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.
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
- Set retries to zero locally and two on CI. Use
retries: process.env.CI ? 2 : 0so flakes surface loudly during development but a single slow CI machine does not redden the whole build. - Keep the test timeout generous but finite. Leave
timeoutnear 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. - Tune expect.timeout to your app's latency. Set
expect.timeoutto 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. - Bound actions and navigations explicitly. Set
actionTimeoutandnavigationTimeoutin theuseblock so a hung click or a never-resolving navigation fails fast with an error naming the step, instead of consuming the entire test budget. - Override per test, never globally, for outliers. Use
test.setTimeout()ortest.describe.configure({ timeout })for the few genuinely slow tests, leaving the global defaults tight for everything else. - 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 withscreenshot: '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.
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.