Playwright architecture, selector reliability, and advanced interaction patterns.

Web-First Assertions

Almost every intermittent end-to-end failure traces back to one mistake: the test read the page at a moment the page had not finished changing. A button was still disabled, a heading still said Loading…, a row count was still one short. The DOM reached the right state fifty milliseconds later, but the assertion had already resolved and reported a mismatch. Playwright's answer is the web-first assertion — an expect() call that receives a locator rather than a value, re-queries the live page on a loop, and only reports a failure once a deadline expires. This guide, part of Advanced Interactions & Test Assertions, covers the retry model, the matcher catalog, the escape hatches for conditions that are not locators, and the failure modes that survive all of it.

The single rule underneath everything here is that the argument to expect() decides whether the check retries. expect(locator).toHaveText('Done') retries. expect(await locator.textContent()).toBe('Done') does not, because the await collapsed the page into a string before expect() ever ran. Those two lines look nearly identical in review and behave completely differently under load.

The conceptual model: assertions that own a deadline

A classic unit-test assertion is a pure function of a value that already exists. Browser state is not that. Between the click that triggers a mutation and the paint that makes it visible sit a network round trip, a framework render pass, a hydration step, and possibly a CSS transition. There is no synchronous instant at which the DOM is "ready"; there is only a window during which it becomes correct. An assertion that samples once has to guess where that window is, and a fixed waitForTimeout() before it is only a guess with extra latency baked in.

A web-first assertion inverts the responsibility. Instead of you predicting when to look, the matcher takes a deadline and a predicate and keeps looking. It resolves the locator from scratch on every attempt — this matters, because a locator is a description of how to find an element, not a captured reference, so a React re-render that replaces the node does not invalidate it. It reads the property under test, compares it to the expectation, and either resolves immediately or schedules another attempt. When the deadline passes, it throws with the last observed value attached, which is what makes the error message diagnostic rather than merely negative.

The retry loop inside a web-first assertion An assertion re-queries the DOM on a loop until the expectation matches or the deadline is reached, at which point it reports the last observed value. await expect(loc) assertion starts re-query the DOM compare expected match, so pass no retry needed deadline hit Timed out 5000ms last value shown retry on the next tick
The matcher, not the test author, decides when to look again; only an expired deadline turns a mismatch into a failure.

The polling interval is deliberately not part of the public contract for locator matchers. Playwright re-evaluates frequently enough that the granularity is irrelevant to correctness, and treating an interval as tunable is a sign the assertion is being used to paper over an application problem. The only knobs that matter are the deadline and, for the non-locator forms discussed later, an explicit backoff schedule when the subject being polled is an expensive external call.

This is a different mechanism from the auto-waiting built into actions. When you call click(), Playwright runs actionability checks — the element must be attached, visible, stable in position, able to receive events, and enabled — before it dispatches the event. Those checks protect the action. Web-first assertions protect the verification. Both retry, both share a philosophy, and neither replaces the other: an action that succeeded proves the element was clickable, not that the application did the right thing afterwards.

Prerequisites

You need @playwright/test and its bundled expect, not a standalone Jest or Chai expect. The import that matters is import { test, expect } from '@playwright/test'; — importing expect from anywhere else silently removes every retrying matcher, and expect(locator).toBeVisible() will fail with toBeVisible is not a function. All examples below assume TypeScript, a playwright.config.ts created by npm init playwright@latest, and a baseURL so navigations can use paths.

You should also be comfortable with locators as descriptions rather than handles. If you are still reaching for page.$() and element handles, the retry model will not work for you, because a handle points at a node that may have been discarded. Build locators from accessible queries first — the reasoning is laid out in getByRole & Accessibility Selectors — and keep dynamic-content synchronization in mind as described in Handling Dynamic Content.

The retry budget: expect timeout inside test timeout

Every retrying matcher gets its own timeout, defaulting to 5000 ms, configured globally under the expect key and overridable per call. That budget is nested inside the test timeout, which defaults to 30 000 ms. The nesting is strict: an assertion cannot outlive the test, so a 20-second assertion inside a test that has three seconds left will fail after three seconds with a test-timeout error rather than an assertion error. Reading that distinction correctly saves a great deal of confused debugging.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,          // per-test budget; assertions are nested inside it
  expect: {
    timeout: 7_000,         // default deadline for every retrying matcher
  },
  use: {
    baseURL: 'http://localhost:3000',
    actionTimeout: 10_000,  // budget for click/fill actionability checks
    trace: 'on-first-retry',
  },
});

Raise the global expect.timeout only when the whole application is slow. A blanket 30-second assertion timeout hides regressions: a page that used to settle in 200 ms and now takes 12 seconds still passes, and the suite gets slower without anyone noticing. The better pattern is a modest global value and a targeted override on the small number of genuinely slow checks — a report that renders after a batch job, an upload that must clear virus scanning.

import { test, expect } from '@playwright/test';

test('report finishes generating', async ({ page }) => {
  await page.goto('/reports');
  await page.getByRole('button', { name: 'Generate' }).click();

  // The global default stays low; only this known-slow step is widened.
  await expect(page.getByRole('status')).toHaveText('Report ready', {
    timeout: 60_000,
  });

  // Back to the default deadline for everything that follows.
  await expect(page.getByRole('link', { name: 'Download CSV' })).toBeEnabled();
});
Assertion deadlines nested inside the test timeout The test timeout is the outer budget; each assertion deadline sits inside it and is subdivided into short polling ticks. test timeout 30 000 ms outer budget for the whole test body expect timeout 5 000 ms assertion one assertion two poll tick, tens of ms elapsed time; an assertion never outlives the remaining test budget
Each retrying matcher spends its own deadline in short polls, and every deadline is clipped by whatever remains of the test timeout.

The locator matcher catalog

The retrying matchers divide cleanly by what they read off the element. Visibility and lifecycle: toBeVisible(), toBeHidden(), toBeAttached(), toBeInViewport(). Interaction state: toBeEnabled(), toBeDisabled(), toBeEditable(), toBeFocused(), toBeChecked(). Content: toHaveText(), toContainText(), toHaveValue(), toHaveValues(), toHaveCount(). Markup and styling: toHaveAttribute(), toHaveClass(), toHaveId(), toHaveCSS(), toHaveJSProperty(), toHaveRole(). Page-level: toHaveURL() and toHaveTitle(). Response-level: toBeOK(). Snapshot-level: toHaveScreenshot() and toMatchAriaSnapshot().

Two of these deserve special attention because they eliminate whole categories of manual waiting. toHaveCount() retries the count of a locator that matches many elements, so it replaces the pattern of reading count() in a loop while a list streams in. And toBeAttached() distinguishes "present in the DOM but not painted" from "not rendered at all", which matters for elements that mount hidden and reveal on a transition.

import { test, expect } from '@playwright/test';

test('search results settle before every check', async ({ page }) => {
  await page.goto('/search');
  await page.getByRole('searchbox', { name: 'Query' }).fill('playwright');
  await page.getByRole('button', { name: 'Search' }).click();

  const results = page.getByRole('listitem');

  // Retries the count while the list streams in; no manual polling loop.
  await expect(results).toHaveCount(20);

  // Attached but not yet painted is a real state during a mount transition.
  await expect(page.getByRole('region', { name: 'Filters' })).toBeAttached();

  // Reads a live computed property, re-read on every attempt.
  await expect(page.getByRole('button', { name: 'Load more' })).toBeEnabled();

  // Page-level matchers retry too — useful right after a client-side route change.
  await expect(page).toHaveURL(/\/search\?q=playwright/);
});

Choosing the matcher that names the actual requirement is worth more than it looks. toBeVisible() on a success banner says the user can see confirmation. toHaveCount(20) says the page has a full result set. expect(await page.locator('li').count()).toBe(20) says neither — it says the count happened to be twenty at one arbitrary instant, and it is the exact line that turns green locally and red on a loaded CI runner.

Retrying matcher compared with a one-shot value check A four-row comparison of how a locator matcher and an awaited value check differ in call shape, timing, failure output, and intended use. retrying matcher one-shot value check call expect(loc).toHaveText() expect(await loc.textContent()) timing polls until expect.timeout reads the DOM exactly once on failure prints the last seen value fails on the first race lost use for any live UI state values already resolved
The two forms differ by one await, and that await is the difference between a stable check and a coin flip.

Actionability waiting is not verification

A recurring misunderstanding is that because click() and fill() already wait, assertions are optional decoration. They are not, and the difference is worth being precise about. Before dispatching an action, Playwright checks that the element is attached to the DOM, visible with a non-empty bounding box, stable for two consecutive animation frames, able to receive pointer events at the point it will be clicked, and enabled. Those checks are about the feasibility of the interaction. They say nothing about whether the interaction produced the right outcome.

The gap shows up in the most common shape of test there is: click a button, then read the result. The click waited for the button to be clickable, then returned as soon as the event was dispatched — not when the request it triggered completed, not when the response rendered. Any read that follows without its own retry is racing a network round trip. This is also why an action succeeding is not evidence a preceding assertion was unnecessary: fill() will happily type into an input that a validation library is about to clear on re-render.

import { test, expect } from '@playwright/test';

test('actionability and verification are separate concerns', async ({ page }) => {
  await page.goto('/settings/billing');

  // Actionability: waits for attached, visible, stable, enabled, hit-testable.
  // Returns the moment the event is dispatched — nothing more is guaranteed.
  await page.getByRole('button', { name: 'Update plan' }).click();

  // Verification: the only thing that proves the mutation actually landed.
  await expect(page.getByRole('status')).toHaveText('Plan updated');
  await expect(page.getByTestId('current-plan')).toHaveText('Team');

  // A read without its own retry races the round trip the click started.
  // const plan = await page.getByTestId('current-plan').textContent(); // wrong
});

There is one more subtlety in the actionability list. toBeVisible() and the click precondition use the same definition of visible — a non-empty bounding box and no visibility: hidden — but an element with opacity: 0 counts as visible to both. If your application fades content in, an assertion can pass while nothing is on screen. Assert on a class, a data- attribute, or toHaveCSS('opacity', '1') when the fade itself is the thing under test.

Matching text without over-specifying

toHaveText() compares the element's full normalized text; toContainText() checks for a substring. Whitespace is normalized on both sides — leading and trailing spaces are trimmed and internal runs collapse — so a template that indents its markup does not force you to hand-write \n in the expectation. When the exact wording is owned by product copy that changes weekly, assert with a regular expression on the part that carries meaning instead: toHaveText(/\d+ items? selected/) survives a rewrite of the surrounding sentence.

Both matchers accept an array, which changes their behaviour in a way that is easy to miss. Given an array, the locator must resolve to exactly that many elements, and each element's text is compared to the corresponding entry. That gives you an ordered list assertion — content and length and order in one retrying call — which is exactly what you want for a sorted table or a breadcrumb trail.

import { test, expect } from '@playwright/test';

test('sorting produces the expected order', async ({ page }) => {
  await page.goto('/inventory');
  await page.getByRole('button', { name: 'Sort by price' }).click();

  const names = page.getByTestId('product-name');

  // Array form asserts count, order, and content together, and retries as a unit.
  await expect(names).toHaveText(['Adapter', 'Cable', 'Dock', 'Monitor']);

  // Regex keeps the assertion aligned to meaning, not to marketing copy.
  await expect(page.getByRole('status')).toHaveText(/\d+ products? sorted/);

  // Substring match when the element wraps extra decoration around the value.
  await expect(page.getByTestId('total')).toContainText('1,240.00');

  // ignoreCase avoids brittle assertions on CSS-uppercased labels.
  await expect(page.getByRole('button', { name: 'Sort by price' }))
    .toContainText('sort by price', { ignoreCase: true });
});

One caveat: toHaveText() reads text content, including text that is present in the DOM but visually clipped or hidden by CSS. If you need what a user can actually read, pass { useInnerText: true }, which uses element.innerText and therefore respects rendering. The difference shows up most often with screen-reader-only spans and with truncated table cells.

When the thing you are asserting is not a locator

Plenty of real conditions are not element properties — a value in localStorage, a row that must appear in the database, an eventually-consistent search index, a counter exposed on window. Two APIs extend retry semantics to those.

expect.poll(fn) runs an async function repeatedly and applies any standard matcher to its return value. It accepts timeout, a custom message, and an intervals array that controls the backoff schedule, which is how you avoid hammering an external service every 100 ms. expect(fn).toPass() is the broader form: it retries an entire block of code, including the assertions inside it, until the block completes without throwing. Use toPass() when several conditions must become true together, or when the check requires an action — clicking a refresh button between attempts, for example.

import { test, expect } from '@playwright/test';

test('eventual consistency without hand-rolled waiting', async ({ page, request }) => {
  await page.goto('/orders/new');
  await page.getByRole('button', { name: 'Submit order' }).click();

  // Retry an arbitrary async value with backoff instead of a fixed schedule.
  await expect
    .poll(async () => {
      const res = await request.get('/api/orders/latest');
      return res.status();
    }, {
      message: 'order should be readable from the API',
      intervals: [500, 1_000, 2_000, 5_000], // widening backoff
      timeout: 30_000,
    })
    .toBe(200);

  // toPass retries a whole block, including the action inside it.
  await expect(async () => {
    await page.getByRole('button', { name: 'Refresh' }).click();
    await expect(page.getByRole('row', { name: /ORD-\d+/ })).toBeVisible();
  }).toPass({ timeout: 20_000, intervals: [1_000, 2_000, 4_000] });
});

Reach for these only when no locator matcher expresses the requirement. A toPass() block wrapped around something a plain toBeVisible() would have covered is slower, produces a worse error message, and hides which of several inner assertions actually failed. When the underlying condition is a network response rather than an eventual value, pair it with the interception patterns in Network Interception Basics and assert on the captured response directly.

Choosing the right assertion form A decision tree mapping four kinds of subject — locator state, async value, block of steps, plain value — onto the matching assertion API. what are you asserting? locator DOM state async value a block of steps plain value toBeVisible() retries expect.poll() retries toPass() retries toEqual() no retry only the last column samples once; everything else owns a deadline
Match the assertion form to the subject: locator matchers first, poll or toPass for non-DOM conditions, plain matchers only for values already in hand.

Negation, and the assertions that quietly hide races

Every retrying matcher can be negated with .not, and negation inverts the polling condition rather than the result. await expect(spinner).not.toBeVisible() retries until the spinner is gone or the deadline expires; it does not sample once and report "was visible". That is the behaviour you want for teardown of transient UI.

The hazard is different: a negative assertion passes trivially before the thing it denies has had a chance to appear. await expect(page.getByRole('alert')).not.toBeVisible() immediately after a click passes on the very first attempt, because the alert has not rendered yet — and it would have passed identically if the alert were about to render 300 ms later. The assertion proves nothing about the interval you care about. Anchor a negative check to a positive one that establishes the timeline: assert that the success state arrived, then assert the error state is absent.

import { test, expect } from '@playwright/test';

test('saving shows success and no validation error', async ({ page }) => {
  await page.goto('/profile');
  await page.getByLabel('Display name').fill('Ada Lovelace');
  await page.getByRole('button', { name: 'Save' }).click();

  // Positive anchor first: this proves the round trip actually completed.
  await expect(page.getByRole('status')).toHaveText('Profile saved');

  // Only now is the absence of an error meaningful, because time has passed.
  await expect(page.getByRole('alert')).toHaveCount(0);

  // Retries until the spinner detaches rather than sampling once.
  await expect(page.getByTestId('saving-spinner')).not.toBeVisible();
});

toHaveCount(0) is often the clearer way to express "none of these exist", because it names the quantity being asserted and produces an error listing how many were actually found. The same anchoring discipline underpins the flake work described in Detecting and Fixing Flaky Playwright Tests.

Messages, per-assertion options, and expect.configure

An expect() call takes an optional description as its second argument, which appears in the failure output and, more usefully, as the step title in the HTML report and the trace. On a suite with hundreds of toBeVisible() calls, that label is what turns a report into something a non-author can read.

import { test, expect } from '@playwright/test';

// A pre-configured expect for a screen known to render slowly.
const slowExpect = expect.configure({ timeout: 20_000 });

test('dashboard renders every widget', async ({ page }) => {
  await page.goto('/dashboard');

  // The description becomes the step name in the HTML report and trace.
  await expect(page.getByRole('heading', { level: 1 }), 'dashboard title renders')
    .toHaveText('Overview');

  // Reuses the widened deadline without touching the global config.
  await slowExpect(page.getByTestId('revenue-chart')).toBeVisible();
  await slowExpect(page.getByTestId('revenue-chart')).toHaveAttribute('data-loaded', 'true');
});

expect.configure() returns a new expect with baked-in options — timeout, and soft for accumulating failures. Keeping a couple of configured variants next to a page object is cleaner than sprinkling { timeout: 20_000 } across twenty call sites, and it keeps the global default honest. The related structural patterns live in Playwright Config & Fixtures and Page Object Model Design.

Failure modes and debugging

A retrying assertion converts most timing bugs into a clear message, but it also produces a small set of failures whose text is easy to misread. Learning to classify them by the received value — not by the fact that a timeout occurred — is what separates a five-minute fix from an afternoon of raising deadlines at random. Every case below is distinguished by what the call log reports, and the call log is printed on every failure without any extra configuration.

Timeout with a plausible received value. The canonical output looks like this:

Error: Timed out 5000ms waiting for expect(locator).toHaveText(expected)

Locator: locator('#status')
Expected string: "Complete"
Received string: "Pending"
Call log:
  - expect.toHaveText with timeout 5000ms
  - waiting for locator('#status')
  -   locator resolved to <span id="status">Pending</span>
  -   unexpected value "Pending"

The received value is the last one observed, not the first. Pending here means the element existed for the whole five seconds and never changed — an application problem, or a missing trigger, not a timing one. Raising the timeout will not help.

Received <element(s) not found>. The locator matched nothing for the entire deadline. That is a selector problem, not a waiting problem. Check the query in the Playwright Inspector locator picker before touching timeouts.

strict mode violation: locator('button') resolved to 3 elements. Retrying matchers require exactly one element, except for the multi-element matchers (toHaveCount, and the array forms of toHaveText and toContainText). Narrow with filter(), a role name, or first() when the ambiguity is genuine — the full treatment is in Resolving Strict Mode Violations.

expect(...).toBeVisible is not a function. expect came from Jest, Chai, or expect the standalone package rather than from @playwright/test. Fix the import.

Test timeout instead of assertion timeout. The error reads Test timeout of 30000ms exceeded and names no matcher. The assertion's own deadline was longer than the remaining test budget, so the test died first. Either shorten the assertion or raise timeout for that test with test.setTimeout().

The assertion passes but the value is wrong. Usually a mocked response being asserted against itself, or an assertion on an element that exists in a hidden template. Read the DOM snapshot at the failing step in the Trace Viewer, where each assertion is recorded as its own step with before and after snapshots — the fastest way to see what the matcher was actually looking at.

CI/CD considerations

Assertion timeouts behave differently on a shared CI runner than on a developer laptop, because the constraint is CPU contention rather than network latency. Ten Playwright workers on a four-core runner will each get a fraction of a core, and a render that took 80 ms locally can take 900 ms there. The correct response is to cap workers relative to the runner's cores rather than to inflate every deadline; the sharding and worker-count trade-offs are covered in CI/CD Integration and the timeout tuning specifics in Configuring Retries and Timeouts for Stable CI.

Set trace: 'on-first-retry' so a failing assertion produces a trace without paying the recording cost on every green run. Combine it with retries: 2 on CI and zero locally: a test that fails then passes is reported as flaky, and flaky-with-a-trace is a bug report you can act on. Resist the reflex of raising expect.timeout after the first red build; a deadline chosen to survive the worst runner you have ever seen also masks every performance regression from that point on.

Parallelism interacts with deadlines in a way that is worth measuring rather than guessing. Two workers on a runner that also hosts the application under test will contend with the server as well as with each other, so the tail latency that assertions must absorb is a function of both. Record the wall-clock duration of the slowest ten tests on every build and treat a sustained shift as a signal, not noise. If a single screen is responsible for most of the tail, widen that screen's assertions with an expect.configure() variant rather than moving the global number, so the cost stays visible in code review.

Timing assertions are worth adding explicitly rather than implicitly. If a page must render within two seconds, encode that as a short-timeout assertion — await expect(page.getByRole('main')).toBeVisible({ timeout: 2_000 }) — so the budget is a stated requirement instead of an accident of the global default. And keep an eye on total suite time as an indicator: a suite whose runtime creeps upward while staying green is usually one whose assertions are absorbing an application slowdown inside generous deadlines.

Deep dives beneath this guide

Three areas extend this material past the single-assertion case. Soft Assertions for Multi-Check Tests shows how expect.soft() collects several failures in one run so a layout audit reports every problem instead of stopping at the first. Writing Custom expect Matchers covers expect.extend(), the matcher contract, and how to keep retry semantics and readable diffs in a domain-specific assertion. Asserting on API Responses Alongside UI pairs expect(response).toBeOK() and payload checks with the rendered result, so a test proves the backend and the interface agree.

Beyond those, the same retry model underpins the pixel comparisons in Visual Regression TestingtoHaveScreenshot() is a retrying matcher that re-captures until two consecutive frames match — and the input verification patterns in Form Automation & Input Handling.

Frequently Asked Questions

What actually makes an assertion "web-first"?

The argument. When you pass a locator, a Page, or an APIResponse to expect(), Playwright knows the subject is live and re-evaluates it on a loop until the expectation holds or the deadline expires. When you pass a value — anything you produced with await before the expect() call — the subject is a frozen snapshot and the comparison happens once.

There is no separate configuration switch for this. expect(page.getByRole('alert')).toBeVisible() retries and expect(await page.getByRole('alert').isVisible()).toBe(true) does not, purely because of where the await sits.

Should I raise expect.timeout when tests fail in CI?

Almost never as a first move. A timeout failure means the condition did not become true within the budget, and the received value in the error tells you which kind of problem you have: a stale value that never changed points at the application or a missing trigger, while <element(s) not found> points at the locator. Both are unaffected by a longer deadline.

Raise it deliberately for individual operations that are genuinely slow — report generation, large uploads, virus scanning — using the per-assertion timeout option or an expect.configure() variant, and leave the global default low enough that a real slowdown still turns the suite red.

Do I still need waitForSelector or waitForTimeout?

page.waitForTimeout() has no place in a committed test; it either sleeps longer than needed or not long enough, and it is the single most common cause of suites that pass on a fast machine and fail on a loaded one. waitForSelector() is largely redundant because assertions and actions already wait for their own preconditions, and a toBeVisible() assertion documents the intent that a bare wait leaves implicit.

The genuine gaps are covered by expect.poll() for an async value and expect(fn).toPass() for a block that must eventually succeed, both of which retry with an explicit deadline and produce a real failure message.

How do I assert that something never appears?

Anchor the negative check to a positive one. A bare not.toBeVisible() immediately after an action passes on the first attempt, before the element could have rendered, so it proves nothing. Assert first that the expected outcome arrived — a success status, a settled row count, a URL change — and only then assert the absence.

For counting, toHaveCount(0) is usually clearer than not.toBeVisible(), because the failure message reports how many elements were actually found instead of just stating that something was visible.

Why does my assertion fail with a strict mode violation?

Single-element matchers require the locator to resolve to exactly one node, and they re-check that on every retry, so a list that grows during the test can turn a passing assertion into a violation mid-run. Narrow the locator with filter({ hasText }), a role and accessible name, or an explicit nth() — and if the ambiguity is real, switch to toHaveCount() or the array form of toHaveText(), which are designed for multiple matches.

Back to overview