Playwright & Web Automation Hub

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.

What each timeout bounds

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

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

  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.
  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 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.

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.

Back to overview