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.
In this guide9 sections
- Classify the Asynchronous Condition
- Use expect.poll for One Observable Value
- Set Intervals From System Behavior
- Use toPass for a Coherent Assertion Block
- Keep Retry Callbacks Idempotent
- Distinguish Polling From Test Retries
- Analyze Failures by Last Observation
- Operational Checklist for Retrying Assertions
- 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.
01 / async condition
Async condition
Identify the external or delayed state the test must observe.
02 / poll callback
Polling callback
Read one value or evaluate one coherent assertion block.
03 / backoff interval
Backoff interval
Wait according to a cadence that fits system behavior and load.
04 / matcher result
Matcher evaluation
Compare the newest observation with the expected invariant.
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.
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.
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 pattern | Likely problem | Better response |
|---|---|---|
| Value never leaves initial state | Trigger failed or worker never started | Verify the initiating request and operation ID |
| Value alternates between states | Competing writers or stale replicas | Record timestamps and source endpoint |
| Poll receives repeated transport errors | Service unavailable or wrong route | Separate transport readiness from business status |
| API converges but UI does not | Client refresh or cache problem | Assert invalidation and UI update independently |
| Local pass, CI timeout | Capacity or environment contract differs | Measure queue timing and assign a justified project budget |
| Duplicate records appear | Callback repeated a mutation | Move 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.pollfor one returned value andtoPassfor a coherent block. - Trigger mutations once, then retry read-only observations.
- Pass an explicit timeout to
toPasswhen 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.
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.
- 01Playwright documentation
Microsoft
Canonical API, locator, fixture, browser, and test-runner behavior.
- 02Playwright best practices
Microsoft
Official guidance for resilient tests, isolation, and user-facing locators.
- 03
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.
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright API Testing: Validate APIs Inside Your E2E Suite
Playwright API testing guide covering request contexts, setup flows, assertions, authentication, fixtures, UI strategy, and CI-ready checks.
GUIDE 02
Flaky Tests: Causes and How to Fix Them
Learn how to fix flaky tests with root cause analysis, stable waits, quarantine strategy, CI retries policy, and practical Playwright examples.
GUIDE 03
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
GUIDE 04
What Is a Good API Response Time?
What is a good API response time? Learn p95 vs average targets, SLA vs SLO, industry benchmarks, and how to set latency goals your API can actually meet.