PRACTICAL GUIDE / playwright expect poll

Retrying Async State with expect.poll and expect.toPass in Playwright

Use Playwright expect.poll and expect.toPass for bounded asynchronous checks, with intentional intervals, idempotent probes, and useful failures.

By The Testing AcademyUpdated July 11, 20269 min read
All field guides
In this guide9 sections
  1. Classify the Asynchronous Condition
  2. Use expect.poll for One Observable Value
  3. Set Intervals From System Behavior
  4. Use toPass for a Coherent Assertion Block
  5. Keep Retry Callbacks Idempotent
  6. Distinguish Polling From Test Retries
  7. Analyze Failures by Last Observation
  8. Operational Checklist for Retrying Assertions
  9. Action Plan

What you will learn

  • Classify the Asynchronous Condition
  • Use expect.poll for One Observable Value
  • Set Intervals From System Behavior
  • Use toPass for a Coherent Assertion Block

Asynchronous systems often become correct after the browser action has finished. A report moves through a queue, a webhook updates an account, or an eventually consistent read model catches up with a write. A fixed sleep guesses when that transition will happen; a polling assertion observes whether it happened within a declared budget.

expect.poll and expect.toPass solve different shapes of this problem. Use poll when one asynchronous observation should satisfy one matcher. Use toPass when a coherent block of assertions must eventually pass together. Both are retry mechanisms, so callback design matters as much as timeout syntax.

Classify the Asynchronous Condition

First ask whether a built-in web assertion already covers the condition. await expect(locator).toHaveText(...) retries and usually gives better DOM diagnostics than wrapping text extraction in a poll. Use explicit polling for state outside a locator assertion: an API, database-facing test endpoint, queue, audit record, or calculation returned by application code.

The Playwright assertion guide describes expect.poll as converting a synchronous matcher into an asynchronous polling assertion, while toPass retries a block. That distinction is a useful design rule: a scalar observation belongs in poll; a multi-assertion invariant belongs in toPass.

Animated field map

Bounded Retry for Asynchronous State

A read-only probe is retried on deliberate intervals until its matcher passes or the timeout budget expires.

  1. 01 / async condition

    Async condition

    Identify the external or delayed state the test must observe.

  2. 02 / poll callback

    Polling callback

    Read one value or evaluate one coherent assertion block.

  3. 03 / backoff interval

    Backoff interval

    Wait according to a cadence that fits system behavior and load.

  4. 04 / matcher result

    Matcher evaluation

    Compare the newest observation with the expected invariant.

  5. 05 / bounded result

    Bounded result

    Pass on convergence or fail with evidence at the deadline.

Write down the expected transition, maximum acceptable duration, and source of truth before selecting intervals. Otherwise polling can hide an undefined service-level expectation behind a large timeout.

Use expect.poll for One Observable Value

The callback passed to expect.poll can be asynchronous. Its returned value is fed to the matcher at the end of the chain. A custom message should identify the business transition because it appears in reports whether the assertion passes or fails.

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

test('export reaches the completed state', async ({ page, request }) => {
  await page.goto('/reports')
  await page.getByRole('button', { name: 'Export CSV' }).click()

  const exportId = await page.getByTestId('export-id').textContent()
  if (!exportId) throw new Error('Export ID was not rendered')

  await expect.poll(
    async () => {
      const response = await request.get(`/api/exports/${exportId}`)
      expect(response.ok()).toBeTruthy()
      const body = (await response.json()) as { status: string }
      return body.status
    },
    {
      message: `export ${exportId} should complete`,
      intervals: [250, 500, 1_000, 2_000],
      timeout: 15_000,
    },
  ).toBe('completed')
})

The callback reads state and returns the status. It does not create another export on every attempt. The inner expect(response.ok()) fails the current probe if transport fails, allowing another attempt; whether that is desirable depends on whether transient API errors are part of the system contract.

Return compact values that produce useful matcher output. Returning an entire response object can bury the state difference. If several fields matter together, either return a small normalized object for toEqual or move to toPass.

Set Intervals From System Behavior

Short default-style intervals are suitable for browser state that normally settles quickly. They can be wasteful for a batch job whose status changes no more than once every few seconds. Conversely, a ten-second first delay makes a fast test unnecessarily slow.

Treat timeout and intervals as a budget. The timeout is the maximum observation window, not a delay applied to each attempt. The interval list defines waits between probes; after the list is exhausted, the last interval is reused. Choose a sequence that gathers enough evidence without producing avoidable service load.

Align the poll timeout with the surrounding test timeout. A poll cannot usefully claim a sixty-second budget inside a test that has only thirty seconds available. Reserve time for setup, the triggering action, artifacts, and teardown. When a workflow legitimately takes minutes, consider a lower-level integration test or a dedicated long-running project rather than enlarging every browser test.

Use toPass for a Coherent Assertion Block

expect(async () => { ... }).toPass() retries until every assertion in the callback passes in the same attempt. This is useful when a read model must expose several mutually consistent fields, or when API state and UI state must converge together.

TypeScript
await expect(async () => {
  const response = await request.get(`/api/orders/${orderId}`)
  expect(response.status()).toBe(200)

  const order = (await response.json()) as {
    status: string
    shipmentId: string | null
  }
  expect(order.status).toBe('shipped')
  expect(order.shipmentId).not.toBeNull()

  await page.getByRole('button', { name: 'Refresh order' }).click()
  await expect(page.getByTestId('order-status')).toHaveText('Shipped')
}).toPass({
  intervals: [500, 1_000, 2_000],
  timeout: 20_000,
})

Here the refresh action is deliberately repeatable. If Refresh sends a mutation, this structure would be unsafe. Prefer reloading a read-only view or polling the API first, then make one final UI assertion when the backend has converged.

An important timeout detail is easy to miss: toPass defaults to timeout zero and does not use the configured expect timeout. Pass an explicit value unless a single attempt is genuinely intended. A bare toPass() can otherwise look like a retry while providing no retry window.

Keep Retry Callbacks Idempotent

Every callback may run multiple times. Creating a user, charging a card, advancing a workflow, or acknowledging a message inside it can produce duplicate side effects. The safest callback only reads.

If a mutation cannot be avoided, use an idempotency key and assert the server treats repeats as the same operation. Even then, ask whether the mutation belongs before the polling boundary. A common pattern is: trigger once, capture an operation ID, poll by that ID, and assert the final result once.

Also avoid shared counters or mutable variables that alter the probe's meaning. Logging attempt timestamps is fine; changing the expected value based on the number of retries is not. The condition should represent product state, not the test's persistence.

Distinguish Polling From Test Retries

Polling retries one observation within a single test attempt. A configured test retry reruns the entire test after failure, potentially repeating setup and mutations. They answer different questions. Polling accommodates an expected asynchronous transition; a test retry helps classify or recover from an unexpected test failure.

Nested retries can multiply runtime and hide poor boundaries. A twenty-second poll inside a test with two retries may trigger the workflow three times. Make test data unique and cleanup robust, but do not use test retries as extra poll budget. Report the poll's deadline and last observed value so a failure can be diagnosed without relying on another full attempt.

Soft polling is also a deliberate choice. A soft failure allows later steps to run, which can collect related observations, but it may produce actions based on invalid state. Use it for independent diagnostics, not as permission to continue a workflow that requires the condition.

Analyze Failures by Last Observation

A good async failure states what was last observed and how long the test waited. Attach a compact history when transitions themselves matter.

Failure patternLikely problemBetter response
Value never leaves initial stateTrigger failed or worker never startedVerify the initiating request and operation ID
Value alternates between statesCompeting writers or stale replicasRecord timestamps and source endpoint
Poll receives repeated transport errorsService unavailable or wrong routeSeparate transport readiness from business status
API converges but UI does notClient refresh or cache problemAssert invalidation and UI update independently
Local pass, CI timeoutCapacity or environment contract differsMeasure queue timing and assign a justified project budget
Duplicate records appearCallback repeated a mutationMove the action outside the retry or add idempotency

Do not solve every row by increasing timeout. If the last observation is structurally impossible, such as operation not found, waiting longer only delays useful failure.

Operational Checklist for Retrying Assertions

  • Prefer a built-in locator assertion when the condition is visible DOM state.
  • Use expect.poll for one returned value and toPass for a coherent block.
  • Trigger mutations once, then retry read-only observations.
  • Pass an explicit timeout to toPass when retries are intended.
  • Choose intervals based on state-change frequency and probe cost.
  • Keep the polling budget below the enclosing test budget.
  • Add a business-specific assertion message and preserve the operation ID.
  • Capture the last value or a bounded transition history on failure.
  • Review the interaction between polling, test retries, and cleanup.

Action Plan

Replace one fixed sleep by naming the exact state transition it was attempting to cover. If the source of truth returns one value, implement a read-only expect.poll with an explicit message, intervals, and deadline. If several observations must agree, use a bounded toPass block and audit every statement for retry safety. Finally, force a timeout once and inspect the report. The failure should identify the operation and last condition clearly enough that an engineer can investigate the system rather than guess at a longer delay.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 11, 2026 / Reviewed July 11, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Playwright documentation

    Microsoft

    Canonical API, locator, fixture, browser, and test-runner behavior.

  2. 02
    Playwright best practices

    Microsoft

    Official guidance for resilient tests, isolation, and user-facing locators.

  3. 03
    WebDriver standard

    W3C

    The browser automation protocol specification used by major automation stacks.

FAQ / QUICK ANSWERS

Questions testers ask

When should I use expect.poll in Playwright?

Use expect.poll when an asynchronous probe returns one value that a normal matcher can evaluate, such as a job status, queue depth, or API response code. Keep the probe observational and bound it with a meaningful timeout.

How is expect.toPass different from expect.poll?

expect.poll retries a callback and applies one matcher to its returned value. expect.toPass retries an entire assertion block, which is useful when several observations must become consistent together.

Does expect.toPass use the configured expect timeout?

No. Its default timeout is zero and it does not inherit the configured expect timeout, so pass an explicit timeout when you need a bounded retry window.

Can I perform actions inside a polling callback?

Technically a callback can run arbitrary code, but repeated mutations are risky. Prefer read-only probes; if an action is unavoidable, make it idempotent and account for retries in the test data and assertions.

What polling intervals should a Playwright test use?

Choose intervals from the expected system behavior and load cost. Fast UI state can use short probes, while an external job may need a slower cadence that avoids flooding the service and still fits the test timeout budget.