skip to content

In a Playwright test, why does page.waitForLoadState() after a click often fail to make the test wait?

level: seniorimportance: should knowfreq 44%

answer

  1. It inspects whichever document is current
  2. An already-satisfied state resolves instantly
  3. The navigation must already be committed
  4. Client-side routes fire no load event
  5. Wait for the URL instead

basics

~20 s

Because it inspects whichever document is current when the call runs. If the click has not committed a navigation yet, or never will because the app routes client-side, the old document already satisfies the state and it returns instantly.

solid answer

~50 s

`page.waitForLoadState(state)` resolves when the **current** document reaches the state, and returns immediately if it already has. That makes it a no-op in the two common cases. If the click's navigation has not been committed by the time the call runs, the state is read from the old document, which reached `load` long ago - the wait returns and the test asserts against the previous page. If the app is a single-page issue tracker, the click changes the route through the History API and no new document is ever created, so there is no load event to wait for at all. The right signal is `page.waitForURL('**/issues/42')`, which waits for the main frame to reach a matching URL and treats a History API change as a navigation - or, better still, an assertion on the content the new route renders.

code

typescript · 7 lines
typescript
await page.getByRole('button', { name: 'Create issue' }).click();

// Not this: resolves against the board, which reached load long ago.
// await page.waitForLoadState();

await page.waitForURL(/\/issues\/\d+$/);
await expect(page.getByRole('heading', { name: 'New issue' })).toBeVisible();

go deeper

for a junior

Take away one rule: after a click, wait for the URL or assert on what the new view shows. A load state describes the document that is current right now, which is usually still the old one.

for a middle

Explain the two sentences in the docs that cause it: the navigation must already be committed, and an already-satisfied state resolves immediately. Then explain why a History API route change fires no load event at all.

for a senior

Diagnose it from evidence. A zero-millisecond wait step in the trace plus an assertion that saw stale content is the signature, and the fix is to replace the wait with page.waitForURL or a content assertion, never to stack another wait on top.

for a principal

Own the pattern at suite level. Make the convention be that navigation intent is expressed as a destination, not a moment, and track why waitForLoadState survives anywhere it still does so the exception stays deliberate.

`page.waitForLoadState(state)` resolves when the page reaches a load state, `'load'` by default, and accepts `'load'`, `'domcontentloaded'` or the discouraged `'networkidle'`. Two sentences in its documentation explain nearly every misuse: **the navigation must have been committed when the method is called**, and **if the current document has already reached the required state, it resolves immediately**. ## Why the call becomes a no-op Consider clicking *Create issue* on the board and expecting the issue detail page. 1. The click returns as soon as Playwright has dispatched it. The browser may not have received the response headers yet, so no navigation has been committed. 2. `page.waitForLoadState()` runs and looks at the **current** document - still the board, which fired `load` seconds ago. 3. The condition is already satisfied, so the call resolves immediately. 4. The next assertion runs against the board, sees the old row set, and either fails confusingly or, worse, passes for the wrong reason. Nothing about this is intermittent in an obvious way: it is a race, so it passes on a fast local machine and fails on a loaded CI runner, which is exactly the profile of the flake people spend an afternoon on. ## The single-page case A modern issue tracker usually does not create a new document at all. Clicking a row calls `history.pushState()`, the router swaps the view, and the browser fires **no** `load` and **no** `DOMContentLoaded`. There is nothing for `waitForLoadState()` to wait on in either direction - the current document reached `load` at the initial `page.goto()` and never leaves that state. The call is pure decoration, and removing it changes nothing. ## What to use instead | Signal | Waits for | Good for | |---|---|---| | `page.waitForURL(pattern)` | The main frame to reach a matching URL | A click that routes, client-side or not | | `expect(page).toHaveURL(pattern)` | The same, as a retrying assertion | When the URL is the thing being verified | | `expect(locator).toBeVisible()` | The new view's own content | Almost everything else | | `page.waitForLoadState()` | The current document's lifecycle | A popup you just obtained, and little else | `page.waitForURL()` takes a glob string, a `RegExp`, a `URLPattern` or a predicate over the `URL`; a plain string with no wildcards means exact equality. It accepts its own `waitUntil`, defaulting to `'load'`, and it explicitly counts a History API URL change as a navigation - which is what makes it work where a load state does not. It also replaces the deprecated `page.waitForNavigation()`, which the docs describe as inherently racy for the very reason above: it had to be started before the action that triggered the navigation. ## The remaining honest use `waitForLoadState()` is genuinely useful on a page handle you have just received rather than one you navigated. When a click on the issue detail screen opens a popup, you get the new `Page` object and its document may still be blank; `await popup.waitForLoadState('domcontentloaded')` before reading its title is exactly what the method is for. There the navigation *has* been committed - the popup exists because it was - so the precondition holds. ## Diagnosing it in a real suite - Read the trace: a `waitForLoadState` step that takes **0 ms** is the tell. Real waits show duration. - Look at what the failing assertion actually saw. Old content after a click that should have navigated means the wait resolved against the old document. - Check whether the app routes client-side. If the URL changes without a network document fetch, no load-state wait can ever help. - Replace, do not stack. Adding a second `waitForLoadState('networkidle')` on top is the classic wrong fix; it changes the failure mode rather than the race. The general lesson is that a load state describes a **document**, while the test cares about a **view**. Wait for the URL when routing is the point, and assert on rendered content when it is not.

  • Why is page.waitForNavigation() deprecated in favour of page.waitForURL()?
    Because it is inherently racy: you must start it before the action that triggers the navigation and await it afterwards, and any navigation that commits in between is missed. `page.waitForURL()` states the destination instead of the moment, so ordering stops mattering.
  • What forms can the url argument of page.waitForURL() take?
    A glob string, a `RegExp`, a `URLPattern`, or a predicate receiving the `URL` and returning a boolean. A plain string with no wildcard characters is matched for exact equality, which is a common surprise when a query string is appended.
  • Is there a case where waitForLoadState is the right tool after a click?
    Yes - when the click opened a popup. You hold a new `Page` whose navigation has already been committed, so `await popup.waitForLoadState('domcontentloaded')` before reading its title is meaningful. On the page you clicked in, it usually is not.

saying these in an interview costs you the question

  • Adds waitForLoadState after every click that navigates
  • Thinks a client-side route change fires a load event
  • Escalates to networkidle when the load state does not help
  • Believes waitForLoadState waits for the next navigation
  • Says the wait failed because the timeout was too short