skip to content

Your team's Playwright suite is littered with explicit lifecycle waits; how do you decide which ones stay?

level: principalimportance: nice to knowfreq 30%

answer

  1. Rank the signals, do not ban them
  2. Closest to the assertion wins
  3. Escape hatches need a written reason
  4. Measure how long each wait spends
  5. One timeout budget, decided centrally

basics

~10 s

Rank the signals by how directly they express what the test needs. Assertions on rendered content first, page.waitForURL for routing, locator.waitFor for preconditions, page.waitForFunction as a documented last resort, and networkidle nowhere at all.

solid answer

~40 s

Set an explicit order of preference and make review enforce it. A retrying matcher on the content the test uses comes first, because it states the condition and reports the actual value on failure. `page.waitForURL()` is second, for the one thing a matcher on content cannot express - that routing happened. `locator.waitFor()` is third, for preconditions in fixtures and before raw reads. `page.waitForFunction()` is last, allowed only with a comment naming the invisible condition it observes, because it couples the suite to page internals. `waitUntil: 'networkidle'` and defensive `page.waitForLoadState()` calls do not appear. Then measure: waits that never spend time are dead code, and waits that repeatedly consume their budget are pointing at a product problem the suite is papering over.

code

typescript · 11 lines
typescript
await page.goto('/issues');
await expect(page.getByRole('row')).toHaveCount(25);

await page.getByRole('button', { name: 'Export board' }).click();

// No DOM projection for the export request; poll the resource timeline.
await page.waitForFunction(
  () => performance.getEntriesByType('resource').some(e => e.name.includes('/export/')),
  undefined,
  { polling: 250 },
);

go deeper

for a junior

You do not set the policy, but you can follow it. Reach for an assertion on what you can see first, and if you feel the urge to add a wait, ask what condition you are really waiting for.

for a middle

Be able to justify each wait you write. Know that a wait before an action or beside an assertion on the same element is redundant, and that waitForFunction should carry a comment naming the invisible condition.

for a senior

Bring evidence. Show which waits spend real time and which never do, and turn a load-bearing wait into a product conversation about a slow step rather than a request for a bigger timeout.

for a principal

Own the ranking, the timeout budget and the exceptions. Decide the test, navigation and action timeouts together, and accept that enforcing this occasionally forces the product to expose a readiness state it lacked.

A suite accumulates explicit waits the way a codebase accumulates null checks: each one was added while somebody was staring at a red run, and none was ever removed. The way out is not a cleanup sprint but a stated order of preference that a reviewer can apply in ten seconds, plus a measurement that tells you which surviving waits are real. ## The order of preference | Rank | Signal | Expresses | Allowed | |---|---|---|---| | 1 | `expect(locator)` matchers | The rendered state the test depends on | Always; the default | | 2 | `page.waitForURL(pattern)` | Routing completed, including History API changes | Whenever routing is the step | | 3 | `locator.waitFor({ state })` | A precondition with nothing to assert | Fixtures, helpers, raw reads, `'detached'` | | 4 | `page.waitForFunction(fn)` | A condition with no DOM projection | With a comment naming the condition | | - | `waitUntil: 'networkidle'` | Network quiet, a proxy for readiness | Never in test code | The ranking follows one principle: **prefer the signal closest to what the test asserts**. A matcher on the row a test is about to click is the condition itself. Network quiet is three inferences away from it, which is why it is both the most tempting and the least reliable. ## Rules a reviewer can actually apply - A wait that is immediately followed by an assertion on the same element is **redundant** - the matcher already retries. Delete the wait. - A wait before an action is redundant too; actions run their own actionability checks. Delete it. - A `page.waitForLoadState()` after a click is presumed wrong until someone explains which document it is inspecting. - Any non-default `waitUntil` on `page.goto()` carries a comment naming the page behaviour that forced it - a streaming feed for `'commit'`, a heavy asset for `'domcontentloaded'`. - A `page.waitForFunction()` carries a comment naming the invisible condition and the reason no DOM projection exists. ## Where waitForFunction sits `page.waitForFunction(fn, arg, { polling, timeout })` evaluates a function in the page until it returns a truthy value, resolving to a `JSHandle` of that value. `polling` is `'raf'` by default, meaning the function runs in a `requestAnimationFrame` callback, or a number of milliseconds for an interval. Playwright 1.62 also added `locator.waitForFunction()`, which passes the matching element to the predicate and re-resolves the locator on every retry. It is the right tool exactly when the condition is real and invisible - an export job counter, a resource entry in `performance`, a canvas that has finished painting on the issue burndown chart. It is the wrong tool when it is used to reach into a framework's internals for something the UI already shows, because that couples the suite to a private name that will be renamed without a deprecation. ## Measure, do not just legislate The convention only sticks if the data supports it. 1. **Find the dead waits.** Instrument or sample a few runs and record how long each explicit wait spends. A wait that is always effectively instant is proving nothing and can go. 2. **Find the load-bearing waits.** A wait that regularly consumes most of its budget is not a test smell; it is a **product** signal saying that step is slow. Escalate it rather than raising the timeout. 3. **Watch the timeout budget, not the individual timeouts.** Under the test runner, `navigationTimeout` and `actionTimeout` default to `0`, so the test timeout is the real bound. Deciding those three numbers together is the lead's job; letting each author pick a local `timeout` is how a suite ends up taking twenty minutes to fail. 4. **Re-check after a framework change.** Waits added for a hydration problem often outlive it. Tie the review to the release that fixed the cause. ## What the standard costs Being strict has a real price and it is worth naming. Some tests get harder to write for a week, because the author has to find a condition the UI actually exposes instead of waiting for the network to go quiet - and sometimes the honest conclusion is that the product needs a visible readiness state it does not have. That is a good outcome dressed as a cost: the missing signal was always missing, and the wait was hiding it from everybody, including real users.

  • How do you tell a wait that is protecting the suite from one that is dead code?
    Measure the time it spends. A wait that is effectively instant on every run is asserting nothing and can be deleted. One that regularly consumes most of its budget is load-bearing, and it is telling you a product step is slow rather than that the timeout is too small.
  • What does page.waitForFunction()'s polling option control, and what is its default?
    It chooses how often the predicate is evaluated. The default `'raf'` runs it inside a `requestAnimationFrame` callback, which is the most responsive; passing a number instead runs it on that interval in milliseconds, which is gentler when the predicate is expensive.
  • A team wants to raise every wait timeout to stop intermittent failures. What is your response?
    Push back and separate the two questions. Longer timeouts turn a fast failure into a slow one and hide the slow step causing it. Decide the test-timeout budget centrally, then treat any wait that keeps approaching it as a product finding to escalate.

saying these in an interview costs you the question

  • Treats every explicit wait as equally acceptable
  • Raises timeouts instead of finding the slow step
  • Uses waitForFunction to read framework internals routinely
  • Keeps waits added for bugs that were long since fixed
  • Adds a wait alongside an assertion on the same element