Auto-Waiting and Web-First Assertions
Why expect(locator) retries and expect(value) does not, what actionability checks block on, and how to fix a flaky test without adding a sleep.
Playwright’s reputation for stable tests comes from two mechanisms that are easy to confuse:
actionability checks before every action, and retrying assertions. Both wait. Only
one of them applies to your expect calls, and only if you write them the right way.
What an action waits for
click does not click. It performs a series of checks first, retrying each until it passes:
| Check | Meaning |
|---|---|
| attached | the element is in the DOM |
| visible | it has a non-empty bounding box and is not visibility: hidden |
| stable | its bounding box has not moved for two animation frames |
| enabled | not disabled |
| receives events | a hit test at the click point lands on it, not on an overlay |
Only then does it dispatch the click. That last check is the one that saves you: a click that would have landed on a cookie banner waits instead of silently succeeding.
import { test, expect } from '@playwright/test';
test('clicking waits for the element to be actionable', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
const input = page.getByPlaceholder('What needs to be done?');
await input.fill('buy milk');
await input.press('Enter');
// The row is rendered by a framework update, not present at goto time.
await page.getByRole('listitem').getByRole('checkbox').check();
await expect(page.getByTestId('todo-count')).toHaveText('0 items left');
});
Running 1 test using 1 worker
✓ 1 [chromium] › tests/waiting.spec.ts:3:1 › clicking waits for the element to be actionable (1.2s)
1 passed (1.9s)
When an actionability check never passes, the failure names which one:
Error: locator.click: Timeout 30000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Save' })
- locator resolved to <button disabled name="Save">Save</button>
- element is not enabled
- retrying click action, attempt #14
“element is not enabled” is a real answer. Compare it with a bare TimeoutError from a
tool that just polls for existence.
The assertion that retries — and the one that does not
This is the single most important distinction in the whole framework.
test('reading the value once is a race', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
const input = page.getByPlaceholder('What needs to be done?');
await input.fill('buy milk');
await input.press('Enter');
const text = await page.getByTestId('todo-count').textContent();
expect(text).toBe('1 item left');
});
✘ 1 [chromium] › tests/waiting.spec.ts:16:1 › reading the value once is a race (312ms)
Error: expect(received).toBe(expected) // Object.is equality
Expected: "1 item left"
Received: null
22 | const text = await page.getByTestId('todo-count').textContent();
> 23 | expect(text).toBe('1 item left');
| ^
textContent() resolved before the counter rendered, returned null, and the comparison
happened immediately with no second chance. Note the 312ms — it failed instantly rather
than waiting.
Move the locator inside expect and the same test is stable:
await expect(page.getByTestId('todo-count')).toHaveText('1 item left');
✓ 1 [chromium] › tests/waiting.spec.ts:16:1 › reading the value once is a race (1.1s)
1 passed (1.8s)
The rule is mechanical: anything inside expect() that is a locator retries; anything you
await before expect() does not. If you see await on the same line to the right of
expect(, you have written a race.
The assertions worth knowing
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await expect(page.getByTestId('todo-title')).toHaveCount(3);
await expect(page.getByTestId('todo-count')).toContainText('item');
await expect(page.getByRole('textbox')).toHaveValue('buy milk');
await expect(page.getByRole('listitem').first()).toHaveClass(/completed/);
await expect(page.getByRole('link', { name: 'Active' })).toHaveAttribute('href', '#/active');
await expect(page).toHaveURL(/#\/active$/);
await expect(page).toHaveTitle(/TodoMVC/);
✓ 1 [chromium] › tests/waiting.spec.ts:30:1 › the assertion catalogue (1.4s)
1 passed (2.1s)
Each retries for expect.timeout (5 seconds by default) before failing. Every one takes an
optional { timeout }, and every one can be negated with .not:
await expect(page.getByRole('alert')).not.toBeVisible();
not waits for the condition to stop holding, which is the correct way to assert a
spinner has gone. expect(await spinner.isVisible()).toBe(false) passes trivially before
the spinner ever appears.
The sleep you were about to write
await page.getByRole('button', { name: 'Load more' }).click();
await page.waitForTimeout(2000); // ← don't
await expect(page.getByTestId('row')).toHaveCount(40);
The sleep does nothing except make the test two seconds slower. The assertion below it already waits, and waits only as long as needed. Delete it.
If the condition you need is not an element state, expect.poll retries any function:
test('poll a value that is not a locator', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
const input = page.getByPlaceholder('What needs to be done?');
await input.fill('buy milk');
await input.press('Enter');
await expect
.poll(async () => page.evaluate(() => localStorage.length), { timeout: 5000 })
.toBeGreaterThan(0);
});
✓ 1 [chromium] › tests/waiting.spec.ts:44:1 › poll a value that is not a locator (1.2s)
1 passed (1.9s)
And toPass retries a whole block, which is the escape hatch for a multi-step condition:
await expect(async () => {
const response = await page.request.get('/api/jobs/42');
expect(response.status()).toBe(200);
expect((await response.json()).state).toBe('complete');
}).toPass({ timeout: 30_000, intervals: [1000, 2000, 5000] });
✓ 1 [chromium] › tests/waiting.spec.ts:56:1 › job eventually completes (8.3s)
1 passed (9.0s)
intervals controls the backoff, so a slow poll does not hammer the server.
Soft assertions
A normal failed assertion ends the test on the spot. Sometimes you want the rest of the checks to run anyway — a page with six fields, and you would rather see all six failures than fix them one run at a time.
test('check the whole form in one run', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
await expect.soft(page.getByRole('heading')).toHaveText('TODOS');
await expect.soft(page.getByPlaceholder('What needs to be done?')).toBeVisible();
await expect.soft(page.getByTestId('todo-count')).toBeVisible();
});
✘ 1 [chromium] › tests/waiting.spec.ts:64:1 › check the whole form in one run (10.4s)
Error: expect(locator).toHaveText(expected)
Expected string: "TODOS"
Received string: "todos"
Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
Locator: getByTestId('todo-count')
1 failed
Two failures from one run: the heading case is wrong and the counter is hidden while the list is empty. The test still fails at the end — soft assertions defer the failure, they do not forgive it.
Timeouts, and which one bit you
| Setting | Default | Applies to |
|---|---|---|
timeout | 30s | the whole test |
expect.timeout | 5s | one web-first assertion |
actionTimeout | none (falls back to the test timeout) | one action, e.g. click |
navigationTimeout | none | goto, waitForURL |
globalTimeout | none | the entire run |
// playwright.config.ts
export default defineConfig({
timeout: 60_000,
expect: { timeout: 10_000 },
use: { actionTimeout: 15_000, navigationTimeout: 30_000 },
});
Read the error text to know which fired. “Timed out 5000ms waiting for expect(locator)” is an assertion timeout; “Test timeout of 30000ms exceeded” means the test as a whole ran long, and the last step in the trace tells you where it stalled.
Practice
1. Rewrite expect(await page.locator('.count').isVisible()).toBe(true) as a web-first assertion.
await expect(page.locator('.count')).toBeVisible();
✓ 1 [chromium] › tests/waiting.spec.ts:78:1 › visible (1.0s)
The original reads visibility once. If the element renders 50ms later, it reports false
and fails — and if the element is supposed to be absent, the original passes before the
page has even loaded, which is the more dangerous direction.
2. Assert that the todo counter disappears after the last item is completed.
test('counter hides when the list empties', async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
const input = page.getByPlaceholder('What needs to be done?');
await input.fill('buy milk');
await input.press('Enter');
await page.getByRole('listitem').getByRole('checkbox').check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(page.getByTestId('todo-count')).toBeHidden();
});
✓ 1 [chromium] › tests/waiting.spec.ts:84:1 › counter hides when the list empties (1.3s)
1 passed (2.0s)
toBeHidden passes for both “not in the DOM” and “in the DOM but invisible”. Use
toHaveCount(0) when you specifically mean the element was removed.
3. Set an assertion timeout of 1ms on a passing assertion and read the error.
await expect(page.getByTestId('todo-count')).toHaveText('1 item left', { timeout: 1 });
Error: Timed out 1ms waiting for expect(locator).toHaveText(expected)
Locator: getByTestId('todo-count')
Expected string: "1 item left"
Received: <element(s) not found>
Call log:
- expect.toHaveText with timeout 1ms
- waiting for getByTestId('todo-count')
Useful as a diagnostic: if a 1ms timeout passes, the element was already there and the assertion is not what is slow.
4. Use expect.poll to wait until localStorage holds two todos.
await expect
.poll(async () =>
JSON.parse((await page.evaluate(() => localStorage['react-todos'])) ?? '{}').todos
?.length ?? 0
)
.toBe(2);
✓ 1 [chromium] › tests/waiting.spec.ts:96:1 › storage holds two (1.4s)
expect.poll calls the function repeatedly and applies the matcher to each result, so it
works on anything you can compute — an API response, a file on disk, a count from a database.
Next: actions and forms — typing, uploading, and the events Playwright dispatches for real.