Make Playwright E2E tests reliable with auto-waiting and retries
Replace manual waits with Playwright locator actionability, expect assertions, and retry configuration to reduce flaky UI tests.
03 Aug 2025, 20:47 UTC

The problem: tests that pass locally but fail in CI
A test clicks a button, works on your machine, and then fails in CI because the button was still animating or not yet attached to the DOM. The common fix—adding page.waitForTimeout()—makes tests slower and still does not guarantee the element is ready.
Useful takeaway: let Playwright's built-in locator actionability wait for elements automatically, pair it with retry-able expect assertions, and configure retries only as a safety net.
Desired outcome
End-to-end tests that interact with elements only when they are visible, stable, enabled, and able to receive events, with fewer hardcoded sleeps and less intermittent failure in CI.
Prerequisites
- Node.js and npm installed.
@playwright/testinstalled in the project.- At least one browser project configured in
playwright.config.ts, such as Chromium, Firefox, or WebKit. - Basic familiarity with locators such as
page.locator,getByRole, andgetByText.
This guide assumes Playwright Test 1.x. Locator actionability means Playwright checks that an element is visible, stable (not animating), enabled, and receives events before performing an action such as a click or fill.
Procedure: replace manual waits with auto-waiting
1. Use locator methods instead of hardcoded sleeps
Built-in actions such as click(), fill(), and selectOption() auto-wait until the target locator resolves to a single actionable element. Avoid page.waitForTimeout(), which pauses for a fixed period regardless of UI state.
Run the following commands from the project root where playwright.config.ts lives. No special permissions are required.
// Flaky: fixed sleep does not guarantee the button is ready
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();
// Reliable: click auto-waits for visibility, stability, and enabled state
await page.getByRole('button', { name: 'Submit' }).click();
2. Assert pre-conditions with expect
Use expect(locator) assertions before an interaction when you need to confirm a specific state. These assertions are retry-able: Playwright polls the DOM until the condition is true or the assertion timeout expires.
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Close' }).click();
3. Configure timeouts and retries in playwright.config.ts
Edit playwright.config.ts in the project root. This example sets a 30-second test timeout, a 5-second assertion timeout, two retries, and trace capture on the first retry only.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30000,
expect: {
timeout: 5000,
},
retries: 2,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: devices['Desktop Chrome'],
},
],
});
retries: 2 means a failed test runs again up to two additional times in a fresh browser context. Trace capture starts on the first retry, so the failing attempt is still recorded without storing traces for every passing run.
4. Avoid overusing network idle waits
page.waitForLoadState('networkidle') waits until there are no network connections for at least 500 milliseconds. It can mask slow APIs and lengthen tests. Prefer waiting for a specific locator or assertion instead.
5. Debug interactively
Run PWDEBUG=1 npx playwright test from the project root to open the Playwright inspector and step through actions. Add page.pause() inside a test to halt execution at that point.
Concrete example: before and after
| Flaky pattern | Reliable pattern |
|---|---|
await page.waitForTimeout(1000);
await page.click('.submit-btn'); |
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click(); |
await page.waitForLoadState('networkidle'); |
await expect(page.getByText('Welcome')).toBeVisible(); |
Expected checks
- Run the suite repeatedly to detect flakes:
npx playwright test --repeat-each=10. Zero failures across runs indicates stable timing. - Open the HTML report:
npx playwright show-report. Review action timestamps and retry counts. - Inspect a trace:
npx playwright show-trace. Look for action events showing wait durations and readiness checks. - Confirm test files contain no hardcoded
waitForTimeoutcalls.
Recovery options for persistent failures
- Increase
timeoutorexpect.timeoutif the application genuinely needs more time to render. - Add an explicit
expect(...).toBeVisible()before the interacting action. - Use
page.waitForSelector('selector', { state: 'attached' })only when an element is detached from the DOM and later reattached. - Keep
trace: 'on-first-retry'enabled to capture failing attempts without storing traces for every run.
Risk: raising timeouts or relying on retries can hide the root cause and slow down CI. Investigate flaky tests before increasing limits.
Limitations
- Auto-wait covers standard actionability: visible, stable, enabled, and able to receive events. It does not wait for custom JavaScript-driven UI changes unless those changes affect actionability.
getByRolemay match multiple elements, causing a strict mode violation. Filter by name, for examplegetByRole('button', { name: 'Submit' }).- Mobile viewports and device emulation can change actionability timing; test with the device profiles your users actually use.
- Retries hide flakiness; treat them as a safety net, not a fix.
Practical verification
After applying the changes, run npx playwright test --repeat-each=10 locally. If all repetitions pass, open npx playwright show-report and confirm no test used retries unexpectedly. If a test still fails only intermittently, capture a trace and inspect the actionability log in the trace viewer.
Rollback
Because this procedure modifies playwright.config.ts, roll back by restoring the previous configuration file from version control and removing any added expect preconditions. Do this only if retries or timeouts are masking failures that require a code fix instead.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.