Auto-Waiting and Web-First Assertions
On this page
Most flaky UI tests fail for the same reason: the test checked the page before the page was ready. Playwright attacks that problem in two places, actions and assertions. Understanding both, and the handful of habits that switch them off, is the difference between a suite you trust and one you re-run until it goes green.
Actions wait for actionability#
When you call locator.click(), Playwright resolves the locator and then waits until the element passes a set of actionability checks: it is visible, stable (its bounding box has stopped moving for two animation frames), enabled, not inert, and it actually receives events at the click point. If a cookie banner or a spinner overlay sits on top of the button, Playwright notices that the click would land on something else and keeps waiting.
Different actions check different things. fill() requires the element to be visible, enabled and editable but does not care whether it is animating; hover() does not require it to be enabled. The actionability reference has the full table.
If the checks do not pass within the action timeout, the action fails with a TimeoutError and a log of what it was waiting for, such as "element is not enabled" or "<div class="overlay"> intercepts pointer events". That log is usually the whole diagnosis.
There is a force: true option that skips the non-essential checks. Reach for it only when you understand why the check fails and you are sure a real user could still perform the action; otherwise you are hiding a bug.
Assertions retry until they pass#
Playwright's expect has two families of matchers, and mixing them up is the most common source of flakiness in otherwise well-written suites.
Web-first (auto-retrying) assertions take a locator or page and are async. They re-check the condition until it is true or the assertion timeout runs out (5 seconds by default):
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('alert')).toHaveText('Saved');
await expect(page.getByTestId('todo-item')).toHaveCount(3);
await expect(page.getByLabel('Email')).toHaveValue('ada@example.com');
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page).toHaveTitle(/TodoMVC/);
Negations retry too: await expect(spinner).not.toBeVisible() waits for the spinner to go away.
Generic matchers such as toBe, toEqual and toContain check a plain value once and never retry:
expect(response.status()).toBe(200);
expect(items.length).toBeGreaterThan(0);
They are fine for values you already have. They are wrong for anything read from the page.
The anti-patterns, and what to write instead#
Reading state, then asserting on it.
// Flaky: isVisible() returns immediately, true or false, no waiting
expect(await page.getByText('Saved').isVisible()).toBe(true);
// Correct: retries until visible or timeout
await expect(page.getByText('Saved')).toBeVisible();
The same applies to textContent(), inputValue(), count() and getAttribute() followed by a generic matcher. Each has a web-first equivalent: toHaveText, toHaveValue, toHaveCount, toHaveAttribute.
Fixed sleeps.
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(3000); // "give the API time"
Playwright's own API docs mark waitForTimeout as discouraged and only for debugging. Three seconds is too long on a fast day and too short on a slow CI runner, so you pay in both speed and flakiness. Wait for the signal the user would wait for instead: the toast, the updated row, the new URL.
Waiting for the network to go quiet.
page.waitForLoadState('networkidle') is also documented as discouraged for testing. Modern apps poll, stream analytics and hold open connections, so "no requests for 500 ms" may arrive late or not at all. Assert on what should be on the page.
When you genuinely need to wait for a specific request, wait for that request, and start waiting before you trigger it:
const saved = page.waitForResponse(
(r) => r.url().includes('/api/todos') && r.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Save' }).click();
expect((await saved).ok()).toBe(true);
Why does the example create the page.waitForResponse(...) promise before clicking Save, and only await it afterwards?
A wait only sees events that happen after it starts. Start listening first, then trigger the request, then await the promise; otherwise a fast response races past you.
Soft assertions#
A normal failed assertion stops the test. Sometimes you want to check several independent things on a page and see all failures at once. expect.soft records the failure, marks the test as failed, and carries on:
test('header shows the basics', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
await expect.soft(page).toHaveTitle(/TodoMVC/);
await expect.soft(page.getByRole('heading', { name: 'todos' })).toBeVisible();
await expect(page.getByPlaceholder('What needs to be done?')).toBeEditable();
});
Use soft assertions for checks that do not depend on each other. If step two cannot succeed without step one, a hard assertion gives you a clearer failure.
Retrying things that are not locators#
Two helpers bring retrying behaviour to arbitrary code:
// Poll a value until the matcher passes
await expect
.poll(async () => {
const response = await page.request.get('https://api.example.com/status');
return response.status();
}, { timeout: 10_000 })
.toBe(200);
// Retry a whole block of assertions until all pass
await expect(async () => {
const response = await page.request.get('https://api.example.com/status');
expect(response.status()).toBe(200);
}).toPass({ timeout: 10_000 });
expect.poll gives up after 5 seconds unless you pass a timeout. toPass defaults to a timeout of 0, which means it keeps retrying until the test itself times out, so pass an explicit timeout.
You wrap two API checks in expect(async () => { ... }).toPass() with no options, and the endpoint never returns 200. What happens?
toPass defaults to a timeout of 0, which means retry until the test itself times out. Pass an explicit timeout so the failure names the real problem. The 5 second default applies to expect.poll.
Timeouts, and which one fired#
Several timeouts are in play, and the error message tells you which one expired:
| Timeout | Default | Set with |
|---|---|---|
| Test | 30 seconds |
timeout in config, test.setTimeout()
|
| Assertion | 5 seconds |
expect: { timeout } in config, or { timeout } per assertion |
| Action / navigation | none (bounded by the test timeout) | use: { actionTimeout, navigationTimeout } |
| Whole run | none |
globalTimeout in config |
export default defineConfig({
timeout: 30_000,
expect: { timeout: 10_000 },
use: { actionTimeout: 15_000 },
});
Raising timeouts is occasionally right, for instance a report that really takes 20 seconds to generate. Do it for that one assertion with { timeout: 30_000 } rather than globally, so the rest of the suite still fails fast.
One report page really takes about 20 seconds to render; every other assertion in the suite passes in under a second. What is the best change?
Raise the timeout only where the slowness is real, so the rest of the suite still fails fast. A global raise makes every genuine failure take 30 seconds, and a fixed sleep wastes 20 seconds even on a fast day.
Retries are a safety net, not a fix#
The generated config retries failed tests twice on CI. A test that passes on retry is reported as flaky in the HTML report. Treat that label as a bug report: open the trace from the retry (chapter 5) and find the missing wait. Our documentation on debugging Playwright tests covers the extra artifacts you get when the same tests run on remote browsers.
Every pattern in this chapter has the same root: describe the state the page should reach and let Playwright wait for it, rather than telling Playwright how long to wait.