How do you use Playwright's `expect.poll` to assert that an async function eventually returns an expected value?
answer
- A one-shot expect never runs twice
- Pass the producer, not the produced value
- Chain an ordinary matcher onto the wrapper
- Intervals 100, 250, 500, then 1000
- await expect.poll(fn).toBe(expected)
basics
~10 sHand 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 linesimport { 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
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.
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.
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.
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