skip to content

Why does Playwright discourage waiting on the networkidle load state in tests?

level: middleimportance: should knowfreq 62%

answer

  1. It measures traffic, not readiness
  2. A fixed quiet window of milliseconds
  3. Polling or streaming pages never qualify
  4. Marked discouraged in the API reference
  5. Replace it with a retrying matcher

basics

~20 s

networkidle resolves only after 500 ms with no network connections, which is a proxy for readiness that a polling or streaming app never satisfies. Playwright marks it discouraged and tells you to assert on the rendered result instead.

solid answer

~40 s

`'networkidle'` - available both as `page.goto(url, { waitUntil: 'networkidle' })` and as `page.waitForLoadState('networkidle')` - resolves after 500 ms in which the page opened no network connections. Playwright's API docs mark it **DISCOURAGED** for two reasons. First, it can never resolve: an issue board that polls for new comments, holds a WebSocket open or streams an activity feed is never quiet, so the wait burns the full navigation timeout. Second, when it does resolve it proves the wrong thing - quiet sockets are not rendered rows, and a request that starts 600 ms later is missed entirely. The replacement is a web-first assertion on the state the test depends on, such as `expect(page.getByRole('row')).toHaveCount(12)`, which retries until the condition holds and fails with the actual value.

code

typescript · 6 lines
typescript
// Discouraged: the board polls for new comments, so it is never idle.
await page.goto('/issues', { waitUntil: 'networkidle' });

// Preferred: state the condition the test actually depends on.
await page.goto('/issues');
await expect(page.getByRole('row')).toHaveCount(12);

go deeper

for a junior

Learn the rule before the reasoning: do not put networkidle in a test. Wait on what you are about to use, with an assertion such as expect(locator).toBeVisible(), and let the default navigation gate stand.

for a middle

Explain both failure modes: a polling or streaming page never goes quiet, and a page between two fetches looks quiet while nothing has rendered. Mention that the 500 ms window is fixed and that the docs mark the value discouraged.

for a senior

Diagnose a suite that leans on it. Show how the timeout message points at the wrong cause, and how replacing each one with an assertion on the asserted state makes failures name the real condition instead.

for a principal

Own the convention and its exceptions. Ban networkidle in test code, document the two or three cases where no DOM projection exists, and make the review question be what condition the wait stood in for.

`'networkidle'` is one of the values accepted by `page.goto(url, { waitUntil })` and by `page.waitForLoadState(state)`. It resolves once the page has had **no network connections for at least 500 ms**. It reads like the wait everyone wants - *stop when the page is done talking* - and Playwright's own API reference tags it **DISCOURAGED**, with the instruction to rely on web assertions to assess readiness instead. ## Why it fails in both directions The problem is that network quiet is a proxy for readiness, and a proxy fails in two opposite ways. - **It may never arrive.** An issue tracker that polls `/api/notifications` every few seconds, keeps a WebSocket open for live comments, or streams an activity feed never has a 500 ms gap. The wait then runs to the navigation timeout and the test fails with a timeout that names the wrong cause. - **It may arrive too early.** Quiet means no requests are *in flight*, not that any have been rendered. A board that fetches on mount, renders a spinner, and fires its second request 600 ms later is idle in between - the wait resolves, the test clicks, and the row is not there yet. - **It is timing-shaped, so it drifts.** The gap is fixed at 500 ms and the traffic is not. The same suite passes on a fast developer machine and fails on a loaded CI runner, which is the definition of a flaky wait. - **It hides the real requirement.** Nothing in the test says what it was waiting for, so nobody can tell later whether the wait is still needed. ## What to write instead | Instead of | Write | Because | |---|---|---| | `waitUntil: 'networkidle'` after opening the board | `await expect(page.getByRole('row')).toHaveCount(12)` | The count is the condition; the matcher retries until it holds | | `waitForLoadState('networkidle')` before clicking | Nothing - just act | Every action auto-waits for its target to be actionable | | `networkidle` to let a spinner clear | `await expect(page.getByRole('progressbar')).toBeHidden()` | Names the thing being waited on, and fails with a readable message | Web-first matchers poll until they pass or the expect timeout expires, so they collapse the wait and the check into one line. When they fail, the error carries the actual value - *expected 12, received 3* - rather than *Timeout exceeded while waiting for network idle*, which is the diagnostic difference that matters at 2am. ## The honest exceptions There are a few places where `networkidle` is still defensible, and they share a shape: the thing you care about has **no DOM projection at all**. 1. Capturing a full-page screenshot of a marketing page where late-loading images must have landed, and no element assertion can express *all images*. 2. Driving a third-party page you cannot add test ids to and whose rendering you do not control. 3. A one-off scraping or archival script, outside the test suite, where a rough quiet signal is genuinely good enough. Even then the safer version is usually an assertion on something observable. Note also that the two entry points behave the same way: passing `waitUntil: 'networkidle'` to `page.goto()` and calling `page.waitForLoadState('networkidle')` afterwards are the same condition, so swapping one for the other fixes nothing. ## Turning it into a rule The practical team convention is short: - `networkidle` does not appear in test code; a review comment asks for the assertion it stands in for. - If a page genuinely never fires `load`, the fix is `waitUntil: 'commit'`, not `networkidle` - opposite ends of the lifecycle, and only one of them terminates reliably. - If a wait is unavoidable because the state is invisible, `page.waitForFunction()` on a named condition is preferable, because it says what it is waiting for. The underlying idea generalises: wait on the state the test asserts about, not on the traffic that happened to produce it.

  • Is page.waitForLoadState('networkidle') any better than passing waitUntil: 'networkidle' to goto?
    No - they evaluate the same condition, 500 ms with no network connections, just at different moments. Moving the wait out of `goto` into a separate call changes nothing about whether the page ever goes quiet, so both carry the same failure modes.
  • Name a case where networkidle is still a reasonable choice.
    When the thing you care about has no DOM projection - a full-page screenshot that needs late images, or a third-party page you cannot instrument. Outside a test suite, in a scraping script, a rough quiet signal is often good enough.

saying these in an interview costs you the question

  • Calls networkidle the safest wait for slow pages
  • Thinks networkidle means the app finished rendering
  • Adds networkidle whenever a test is flaky
  • Believes networkidle only counts XHR requests
  • Assumes the 500 ms quiet window is configurable