skip to content

How do you use Playwright's `expect.poll` to assert that an async function eventually returns an expected value?

level: juniorimportance: should knowfreq 46%

answer

  1. A one-shot expect never runs twice
  2. Pass the producer, not the produced value
  3. Chain an ordinary matcher onto the wrapper
  4. Intervals 100, 250, 500, then 1000
  5. await expect.poll(fn).toBe(expected)

basics

~10 s

Hand expect.poll the function, not the value: await expect.poll(() => readRowCount()).toBe(42). Playwright re-invokes the callback and re-applies the matcher until it passes or the timeout expires.

solid answer

~50 s

`expect.poll(fn)` turns an ordinary value assertion into a retrying one. You pass a callback that returns the value (or a promise of it), chain a standard matcher such as `toBe`, `toEqual`, `toContain` or `toBeGreaterThan`, and `await` the whole expression: `await expect.poll(() => readRowCount()).toBe(42)`. Playwright calls the callback, applies the matcher, and on failure waits and calls it again, using probe intervals of `[100, 250, 500, 1000]` ms by default and the configured expect timeout (5 s) unless you pass `{ timeout }`. A `message` option labels the failure in the report. The contrast that matters: `expect(await readRowCount()).toBe(42)` resolves the value first, so Playwright only ever sees one number and cannot retry. Use `expect.poll` for values that have no dedicated matcher — a counter read through `page.evaluate`, a parsed export, a number computed in the test.

code

typescript · 11 lines
typescript
import { expect, test } from '@playwright/test';

test('export finishes writing every transaction', async ({ page }) => {
  await page.goto('/statements/2026-08');
  await page.getByRole('button', { name: 'Export' }).click();

  await expect.poll(
    () => page.evaluate(() => (window as any).statementExport?.rowsWritten ?? 0),
    { message: 'export writer should finish every row', timeout: 10_000 },
  ).toBe(42);
});

go deeper

for a junior

Memorise the shape: await expect.poll(() => value).toBe(expected). What repeats is the callback, so give expect.poll the function and never a value you already awaited.

for a middle

Explain the loop itself: Playwright re-invokes the callback, re-applies the matcher, waits the next probe interval, and stops on the first pass or at the expect timeout.

for a senior

Show judgment about what you poll. A cheap side-effect-free read is safe to repeat; polling something that mutates state, or that merely correlates with readiness, buys a green run and no information.

for a principal

Own the convention. When a suite reaches for expect.poll everywhere, the app usually lacks a signal worth asserting on. Decide where polling is the honest contract and where the product should expose something better.

## What retries in Playwright and what does not Playwright ships two different kinds of assertion. The locator assertions are **auto-retrying**: `expect(locator).toHaveCount(3)` re-queries the page until the condition holds or the expect timeout runs out. Everything else is an **ordinary value assertion** from the Jest-style matcher set: it compares the value you handed it, once, and throws on mismatch. The trap is that `expect(await readRowCount()).toBe(42)` *looks* asynchronous — there is an `await` in the line — but that `await` resolves **before** `expect` is ever called. Playwright receives a plain number. There is nothing left for it to retry, so a value that would have been correct 300 ms later fails instantly. `expect.poll` closes that gap. It is a wrapper that turns any ordinary matcher into a retrying one by taking the **producer** of the value instead of the value. ## The call shape 1. Call `expect.poll(callback, options?)`. The callback takes no arguments and returns a value or a promise of one. 2. Chain a normal matcher onto what it returns — `toBe`, `toEqual`, `toContain`, `toBeGreaterThan`, `toHaveLength`, or their `.not` forms. 3. `await` the whole expression. Without the `await` you leave a floating promise and the failure never reaches the test. ```ts await expect.poll( () => page.evaluate(() => (window as any).statementExport?.rowsWritten ?? 0), { message: 'export writer should finish every row', timeout: 10_000 }, ).toBe(42); ``` ## What happens between probes - Playwright invokes the callback and applies the chained matcher to whatever it returned. - If the matcher passes, the expression resolves and the test continues immediately. - If it fails, Playwright waits the next probe interval and repeats. Intervals default to `[100, 250, 500, 1000]` ms and the last value repeats for the rest of the loop. - The loop is bounded by `timeout`, which defaults to the configured expect timeout — 5 s unless `expect.timeout` in `playwright.config.ts` says otherwise. - On expiry the reported error is the matcher's own failure, showing the last polled value against the expectation, prefixed with the `message` you passed. That is why `message` is worth writing: without it a timed-out poll reads like a bare mismatch. ## Plain expect versus expect.poll | | `expect(await fn())` | `expect.poll(fn)` | |---|---|---| | what you pass | a value, already resolved | the function that produces it | | reads of the value | exactly one | one per probe until pass or timeout | | failure timing | immediate on mismatch | only when the deadline passes | | good for | a value that is already final | a value that becomes correct shortly | ## Choosing what to poll - Poll a **read**, never a mutation. The callback runs many times, so anything with a side effect fires many times too. - Return the **smallest** value the matcher needs. Polling a whole object and asserting deep equality turns one unstable field into an opaque failure. - Keep the callback **cheap**. A probe that takes a second stretches the effective spacing between attempts well past the configured interval. - Do **not** wrap a locator assertion in it. Those already retry on their own; `expect.poll` exists for values Playwright has no matcher for. - Prefer polling a value that genuinely means *done* — a completed row count, a settled status string — over a proxy that happens to flip early. ## On a bank statement page The natural uses are the things the DOM matchers cannot see. After clicking **Export**, the page may expose a background writer's progress on a global object; `expect.poll` on `rowsWritten` reaching 42 is a precise, self-describing wait. The same goes for a balance the app recomputes in a store before it paints, or a number the test derives itself by parsing a downloaded CSV. In each case one value, one matcher, repeated reads. ## Mistakes that quietly disable the retry - `expect.poll(await readRowCount()).toBe(42)` — the value is resolved outside the wrapper, so every probe compares the same stale snapshot. - Dropping the `await` in front of `expect.poll`, which makes the assertion unenforceable and often surfaces later as an unhandled rejection. - Putting a click or a form fill inside the callback, so each probe perturbs the very state being measured. - Leaving the default 5 s timeout on a wait that legitimately needs longer, then blaming the app for flakiness.

  • What happens if you call expect.poll on a value you already awaited?
    You poll a constant. The callback hands back the same snapshot on every probe, so Playwright re-compares one stale value until the timeout and reports the original mismatch. Pass the function itself so each probe re-reads the source.
  • Does expect.poll make sense around the auto-retrying locator assertions?
    No. Those already re-query the page until the expect timeout, so wrapping them adds a second retry loop and nothing else. expect.poll is for values with no dedicated matcher: a number out of page.evaluate, a parsed file, a count the test computes.

Checking a parcel tracking page: refreshing the page is polling, whereas reading a printed screenshot of it tells you the same thing forever.

saying these in an interview costs you the question

  • Thinks expect(await value).toBe() retries like a locator assertion
  • Awaits the callback's value before handing it to expect.poll
  • Adds a fixed sleep before the assertion instead of polling
  • Believes expect.poll requires writing a custom matcher first
  • Forgets to await expect.poll, so failures never reach the test