---
title: Auto-Waiting and Web-First Assertions | TestingBot
description: '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…'
source_url:
  html: https://testingbot.com/resources/courses/playwright/auto-waiting-and-assertions
  md: https://testingbot.com/resources/courses/playwright/auto-waiting-and-assertions.md
---

Course

[Playwright Tutorial: Zero to CI](https://testingbot.com/resources/courses/playwright)

Chapter 4 of 10

1. [1 Playwright Tutorial for Beginners](https://testingbot.com/resources/courses/playwright)
2. [2 Install Playwright and Run a First Test](https://testingbot.com/resources/courses/playwright/install-and-first-test)
3. [3 Locators: Find Elements the Way Users Do](https://testingbot.com/resources/courses/playwright/locators)
4. [4 Auto-Waiting and Web-First Assertions](https://testingbot.com/resources/courses/playwright/auto-waiting-and-assertions)
5. [5 Fixtures, Hooks and Logging In Once](https://testingbot.com/resources/courses/playwright/fixtures-hooks-and-authentication)
6. [6 Debugging: UI Mode, Trace Viewer, Codegen](https://testingbot.com/resources/courses/playwright/debugging-tests)
7. [7 Multiple Browsers, Workers and Sharding](https://testingbot.com/resources/courses/playwright/browsers-parallelism-and-sharding)
8. [8 Run Playwright in CI with GitHub Actions](https://testingbot.com/resources/courses/playwright/github-actions-ci)
9. [9 Run Your Playwright Suite on a Cloud Grid](https://testingbot.com/resources/courses/playwright/run-on-a-cloud-grid)
10. [10 Knowledge check](https://testingbot.com/resources/courses/playwright/knowledge-check)

[Exit course](https://testingbot.com/resources/courses)

 Chapter 4 of 10 Beginner 8 min read 3 quick checks Updated Nov 2026 

# Auto-Waiting and Web-First Assertions
On this page
1. [Actions wait for actionability](https://testingbot.com#actions-wait-for-actionability)
2. [Assertions retry until they pass](https://testingbot.com#assertions-retry-until-they-pass)
3. [The anti-patterns, and what to write instead](https://testingbot.com#the-anti-patterns-and-what-to-write-instead)
4. [Soft assertions](https://testingbot.com#soft-assertions)
5. [Retrying things that are not locators](https://testingbot.com#retrying-things-that-are-not-locators)
6. [Timeouts, and which one fired](https://testingbot.com#timeouts-and-which-one-fired)
7. [Retries are a safety net, not a fix](https://testingbot.com#retries-are-a-safety-net-not-a-fix)

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[#](https://testingbot.com#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](https://playwright.dev/docs/actionability) 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[#](https://testingbot.com#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):

```typescript
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:

```typescript
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[#](https://testingbot.com#the-anti-patterns-and-what-to-write-instead)

**Reading state, then asserting on it.**

```typescript
// 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.**

```typescript
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:

```typescript
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?

 `waitForResponse` must be the first call in a test Creating it starts network recording for the trace The response can arrive before a wait that starts after the click, and would then be missed It makes the click itself wait for the network to go idle

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[#](https://testingbot.com#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:

```typescript
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[#](https://testingbot.com#retrying-things-that-are-not-locators)

Two helpers bring retrying behaviour to arbitrary code:

```typescript
// 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?

 It fails after the 5 second assertion timeout It runs the block once and fails It keeps retrying until the test timeout ends the test

`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[#](https://testingbot.com#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 |

```typescript
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?

 Pass `{ timeout: 30_000 }` to that one assertion Set `expect: { timeout: 30_000 }` in the config Add `page.waitForTimeout(20_000)` before the assertion Raise the test timeout to five minutes

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[#](https://testingbot.com#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](https://testingbot.com/support/web-automate/playwright/debug-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.

[Previous · Chapter 3 Locators: Find Elements the Way Users Do](https://testingbot.com/resources/courses/playwright/locators) [Next · Chapter 5 Fixtures, Hooks and Logging In Once](https://testingbot.com/resources/courses/playwright/fixtures-hooks-and-authentication)

On this page

1. [Actions wait for actionability](https://testingbot.com#actions-wait-for-actionability)
2. [Assertions retry until they pass](https://testingbot.com#assertions-retry-until-they-pass)
3. [The anti-patterns, and what to write instead](https://testingbot.com#the-anti-patterns-and-what-to-write-instead)
4. [Soft assertions](https://testingbot.com#soft-assertions)
5. [Retrying things that are not locators](https://testingbot.com#retrying-things-that-are-not-locators)
6. [Timeouts, and which one fired](https://testingbot.com#timeouts-and-which-one-fired)
7. [Retries are a safety net, not a fix](https://testingbot.com#retries-are-a-safety-net-not-a-fix)

Certification

This course has a free, hands-on certificate.

[How it works](https://testingbot.com/resources/courses/playwright/certification)
