skip to content

Polling Wrappers

expect.poll retries an arbitrary async value and toPass retries a whole block of assertions. Interviewers ask because a plain expect on a number or an API response never retries at all.

on this pageshow

explore

questions

4

In Playwright, when should you reach for `expect.poll` instead of wrapping assertions in `toPass`?

level: middleimportance: must knowfreq 68%

answer

  1. Ask what exactly is being retried
  2. One value versus a whole block
  3. Passing means the body did not throw
  4. Statements inside repeat, actions included
  5. Different timeout defaults: five seconds versus none

basics

~20 s

Use expect.poll when the check reduces to one value and one matcher. Use toPass when a whole block must be retried together: several assertions, or code that can throw. Poll retries a value, toPass retries statements.

solid answer

~50 s

Both retry, but the unit of retry differs. `expect.poll(fn).toBe(x)` re-invokes one callback and re-applies one matcher to its return value; the failure names the matcher and the last value seen. `await expect(async () => { ... }).toPass()` retries the **entire callback body** — every statement and every assertion in it — and passes as soon as the body completes without throwing. Reach for `expect.poll` when the condition is a single computed value: an exported row count, a number pulled out of `page.evaluate`. Reach for `toPass` when it is a combination, such as the balance widget showing the cleared total *and* the transaction table holding 43 rows, or when the producing code can throw. `toPass` is not free: every statement inside repeats on each probe, so any action fires again and again, and its timeout defaults to 0, leaving only the test timeout to end it.

code

typescript · 7 lines
typescript
await expect(async () => {
  const balance = await page.getByTestId('balance').textContent();
  expect(balance?.trim()).toBe('$1,250.00');

  const rows = await page.getByRole('row').count();
  expect(rows).toBe(43);
}).toPass({ timeout: 15_000, intervals: [500, 1_000, 2_000] });

go deeper

for a junior

Learn the two shapes and the one-line difference: expect.poll retries a value you return, toPass retries the whole block of code you write inside it.

for a middle

Explain the pass condition for each and name the default timeouts, including that toPass has none of its own. Say why a conjunction of assertions needs the block form.

for a senior

Argue the tradeoff in review: the coarse wrapper hides which condition was late and repeats any action inside. Prefer the narrow tool and justify the wide one when you use it.

for a principal

Set the house rule. Decide when a block-level retry is an acceptable model of the system and when it is a standing invitation to paper over unstable behaviour across a whole suite.

## Two wrappers, two units of retry Playwright gives you two ways to retry something that is not a locator assertion, and the whole choice comes down to **what** is retried. - `expect.poll(callback).toBe(value)` retries **one value and one matcher**. Playwright calls the callback, applies the chained matcher to the returned value, and repeats on failure. - `await expect(async () => { ... }).toPass()` retries **a block of code**. Playwright runs the callback body; if anything in it throws — an assertion, a parse, a request — the whole body is run again from its first statement. Everything else follows from that difference. ## expect.poll: retrying a value `expect.poll(fn, { timeout, intervals, message })` is the narrow tool. It is the right one when the question you are asking is genuinely a single comparison: *has this number reached 42 yet?* Its failure message is precise, because Playwright knows both the expectation and the last value it polled, so the report reads like an ordinary matcher mismatch rather than a stack trace. Its ceiling is equally clear: one callback return, one matcher. If you find yourself returning a tuple or a synthetic object just to assert three things at once, you have outgrown it — the failure will point at an object diff instead of the thing that was actually late. ## toPass: retrying a block `expect(async () => { ... }).toPass({ timeout, intervals })` inverts the shape. Inside the callback you write ordinary code, including ordinary throwing `expect` calls. A probe **succeeds** when the body returns without throwing; the first throw aborts that probe, Playwright waits an interval, and runs the body again. That makes `toPass` the escape hatch for conditions that are not one value: 1. Several assertions that must hold **at the same moment** — a settled balance *and* a complete transaction table, not one at a time. 2. Code that can throw rather than return a wrong value, such as parsing a payload that is briefly incomplete. 3. A check whose intermediate steps depend on each other, where re-deriving everything from scratch is the only sane retry. ## Side-by-side | | `expect.poll` | `toPass` | |---|---|---| | unit of retry | one callback's return value | the whole callback body | | assertions per attempt | exactly one chained matcher | as many as you write inside | | pass condition | the matcher passes | the body finishes without throwing | | default timeout | the expect timeout, 5 s | 0 — no timeout of its own | | default intervals | `[100, 250, 500, 1000]` ms | `[100, 250, 500, 1000]` ms | | failure message | matcher diff with the last value | whatever threw on the final attempt | | actions inside | should never be there | run again on every probe | ## Rules of thumb - If the check is **one value**, use `expect.poll`. The message alone is worth it. - If the check is **a conjunction**, use `toPass`, and keep the block to reads and assertions. - If a locator assertion already covers it, use **neither** — those retry on their own. - Whatever you choose, give it an **explicit timeout** when the wait is expected to be long, rather than letting the test timeout be the backstop. ## The costs of reaching for toPass by default - **Actions repeat.** A click, a fill, or a navigation inside the block fires on every probe. Sometimes harmless, often the reason the state never settles. - **Timeouts nest.** An auto-retrying locator assertion inside the block carries its own timeout, so a single probe can burn seconds before `toPass` gets to retry at all. - **Failures get vaguer.** You learn that the block did not pass, and see only the last thing that threw — not which condition was consistently late. - **It hides fine-grained flakiness.** Three assertions that each pass 90 % of the time look like one flaky block instead of three separate signals. Read this way, `toPass` is powerful precisely because it is coarse, and `expect.poll` is preferable precisely because it is narrow. Reach for the narrow one first, and let the coarse one earn its place.

  • What exactly counts as a pass for toPass?
    The callback returning without throwing. Every expect inside is a normal throwing assertion, so the first failure aborts that probe and Playwright runs the body again after an interval. When the body reaches its end, toPass resolves and the test continues.
  • Is it ever acceptable to put a click inside a toPass block?
    Only when the action is genuinely idempotent and cheap, such as a Refresh the app tolerates repeatedly, and you understand it fires on every probe. Otherwise move the action above the block and retry the read alone with expect.poll.
  • Why does a timed-out toPass often give a less useful report than expect.poll?
    Because toPass can only show what threw on the last attempt. expect.poll knows the expectation and the last polled value, so its message is a matcher diff. With several assertions in a block you lose which one was consistently late.

saying these in an interview costs you the question

  • Thinks toPass retries only the assertion that failed
  • Puts a click or fill inside a toPass block unthinkingly
  • Says expect.poll can retry several assertions at once
  • Assumes toPass inherits the five second expect timeout
  • Reaches for toPass when one value and one matcher would do
open as a page

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

level: juniorimportance: should knowfreq 46%

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.

open as a page

What probe intervals and timeout defaults do Playwright's `expect.poll` and `toPass` use?

level: middleimportance: should knowfreq 52%

basics

~20 s

Both probe on intervals of 100, 250, 500 then 1000 milliseconds, with the last value repeating. In Playwright 1.63 expect.poll uses the expect timeout, five seconds by default, while toPass defaults to timeout 0 and has none of its own.

open as a page

A Playwright `toPass` block clicks Refresh then asserts the balance, and it times out only in CI. What's wrong?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Everything inside a toPass block re-runs on each probe, so the Refresh click keeps firing and restarting the load being awaited. With toPass defaulting to no timeout, the test timeout ends the run instead of the assertion.

open as a page