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.
What each timeout bounds
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, and assertion 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.actionTimeout— the cap on a single action likeclick()orfill(), default unset (it falls back to the testtimeout).navigationTimeout— the cap ongoto(),waitForURL(), and similar navigations, default unset.
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.
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();
});
});
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. - 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 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.
Verification
Confirm the configuration holds three 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. 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.