A Playwright `toPass` block clicks Refresh then asserts the balance, and it times out only in CI. What's wrong?
answer
- The whole block repeats, action included
- Only fails on the slower machine
- Refresh restarts the load each probe
- No wrapper timeout means test timeout
- Act once outside, retry the read
basics
~20 sEverything 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.
solid answer
~50 sTwo faults compound. First, `toPass` retries the **whole** block, so the Refresh click fires on every probe; on a slow CI machine each probe restarts the fetch before the previous one settles, and the balance never has a quiet moment in which to be correct. Locally the first attempt usually wins, which is why it only fails in CI. Second, `toPass` has no timeout of its own by default, so the loop runs until the 30 s test timeout kills the test — you get "Test timeout exceeded", not the assertion that was failing, and any inner auto-retrying assertion can burn seconds per probe on top. The repair: move the click above the block, retry only the read (`expect.poll` if it is one value), give `toPass` an explicit `timeout`, and shorten inner assertion timeouts so a probe cannot monopolise the budget.
code
typescript · 14 lines// Before: the click repeats on every probe, and toPass has no timeout of its own.
await expect(async () => {
await page.getByRole('button', { name: 'Refresh' }).click();
const balance = await page.getByTestId('balance').textContent();
expect(balance?.trim()).toBe('$1,250.00');
}).toPass();
// After: act once, retry only the read, and bound the loop.
await page.getByRole('button', { name: 'Refresh' }).click();
await expect.poll(
async () => (await page.getByTestId('balance').textContent())?.trim(),
{ message: 'balance should settle after refresh', timeout: 15_000 },
).toBe('$1,250.00');go deeper
Take away the rule: put actions before the retried block and keep only reads and assertions inside it, because everything in the block runs again on each attempt.
Explain the mechanics of both faults, the repeating click and the absent wrapper timeout, and say why the failure is reported as a test timeout instead of an assertion error.
Diagnose from evidence: a burst of identical requests at the probe cadence in the trace, an environment-dependent failure, and nested assertion timeouts eating the budget. Then fix the shape, not the numbers.
Make the pattern hard to write. Decide in review that retried blocks contain reads only, that every wrapper carries an explicit budget, and that repeated actions are a defect rather than a style choice.
## What the block actually does on every probe `await expect(async () => { ... }).toPass()` retries the callback **whole**. Playwright has no memory of which statements already succeeded on a previous attempt, so a block written as ```ts await expect(async () => { await page.getByRole('button', { name: 'Refresh' }).click(); const balance = await page.getByTestId('balance').textContent(); expect(balance?.trim()).toBe('$1,250.00'); }).toPass(); ``` is not "click once and keep checking". It is "click, check, and if the check fails, click again". Every probe issues a new refresh. ## Fault one: the action repeats and resets the state On a developer machine the statement usually loads inside the first probe, so the click happens once and the test passes; nobody notices the shape of the code. In CI the machine is slower and the API is further away, so the first check fails, and the loop starts: 1. Probe 1 clicks Refresh; the balance widget goes to its loading state. 2. 100 ms later probe 2 clicks Refresh again, cancelling or superseding the first load. 3. Each subsequent probe repeats this, and the widget is now permanently mid-refresh. The wrapper meant to absorb slowness is now **causing** it. This is the signature of the bug: it worsens with load, it never reproduces locally, and the trace shows a rhythmic stream of identical requests. ## Fault two: nothing bounds the loop `toPass` defaults to `timeout: 0`, which means no deadline of its own and no inheritance of the expect timeout. So the block probes until the **test** timeout — 30 s by default — terminates the test. Two consequences make the diagnosis harder than it should be: - The failure is reported as a test timeout, not as an assertion failure, so the report does not name the condition that was never met. - Any auto-retrying assertion **inside** the block carries its own timeout (5 s by default). One probe can therefore consume five seconds before the outer loop even gets a turn, so a block that looks like it retries dozens of times may manage only a handful of attempts. ## Repairing it 1. **Move the action out.** Click Refresh once, above the block; retry only the observation. 2. **Narrow the check.** If it reduces to one value, `expect.poll` gives a matcher diff on failure instead of an opaque timeout. 3. **Bound the wrapper.** Pass an explicit `timeout` to `toPass` so a stuck condition fails as an assertion, comfortably inside the test timeout. 4. **Shorten inner timeouts.** Give any nested auto-retrying assertion a short `{ timeout }` so one probe cannot eat the whole budget. ## When the action does belong inside Occasionally re-acting is genuinely the point — a link that is briefly inert, or a control that only becomes live once a background job registers it. Keep those blocks honest: - Make sure the action is **idempotent** and cheap enough to fire ten times. - Widen the `intervals` so the repeat rate matches what the system can absorb. - Never put a state-changing action — submitting a transfer, confirming an export — inside a retried block; a retry then means doing it twice. - Prefer the smallest block that expresses the condition, so the failure still tells you something. ## The lesson to carry out of it A retry wrapper is only safe when the thing it repeats is a **read**. The moment a block mixes acting with asserting, its retry semantics stop being a safety net and become part of the system under test — and a wrapper without an explicit timeout guarantees that when it does go wrong, the report tells you the least useful thing it could have said.
- Why does this failure show up as a test timeout rather than an assertion error?Because toPass defaults to timeout 0, so it never gives up on its own. The runner's test timeout ends the test first, and the report names that instead of the condition that kept failing. An explicit toPass timeout restores the assertion failure.
- How would you confirm the repeated click is the cause rather than a slow backend?Open the trace or the network log for the failing run: a genuine slow backend shows one pending request, while this bug shows a burst of identical refresh requests spaced at the probe intervals. Moving the click above the block and re-running settles it.
- What does a nested auto-retrying assertion do to the block's budget?It spends its own timeout, five seconds by default, inside a single probe. So a block that appears to retry many times may only get a few real attempts. Give nested assertions a short explicit timeout when they sit inside a retried block.
saying these in an interview costs you the question
- Blames CI machine speed without reading the block
- Raises the test timeout to make the failure disappear
- Assumes only the failing assertion is retried
- Puts state-changing actions inside a retried block
- Never sets an explicit timeout on toPass
- Ignores that nested assertions carry their own timeouts