Skip to content

Waiting for Page State Without Sleeps in Playwright

Pages built with JavaScript are not ready when navigation finishes: content arrives after timers, API calls and rendering. Automation code has to wait for the state it needs, and the shortcut — sleep for a while and hope — is both slow and wrong, because no fixed delay covers the slowest case without wasting time on the fast ones. Measured on Python 3.14 with Playwright 1.63 against a test page whose script fetched and displayed a price after a random 50–400 ms delay, 40 pages per strategy: wait_for_timeout(200) after navigation took 421 ms per page and read the price correctly only 31 of 40 times; a 500 ms sleep was correct 40 of 40 times at 739 ms; wait_until="networkidle" was correct at 901 ms; waiting for the element itself with a locator was correct 40 of 40 times at 427 ms — as fast as the broken 200 ms sleep. This guide replaces sleeps with waits on the actual condition.

Prerequisites

1. See what fixed sleeps cost

The sleep-based version reads after a guessed delay:

await page.goto(url)
await page.wait_for_timeout(200)                      # hope the price is there by now
price = await page.locator("#price").text_content()   # 31 of 40 correct

Measured: 421 ms per page and 9 wrong answers in 40 — the page still showed loading whenever its delay exceeded the sleep. Raising the sleep to 500 ms fixed correctness and cost 739 ms per page, about 300 ms of pure waiting on every page whose content arrived sooner. Both failure modes scale badly: the wrong answers are silent (the code reads something), and the wasted time multiplies across thousands of pages. A sleep also cannot adapt to a slow day — when the target site's API slows down, a tuned delay becomes a wrong one.

Verify: grep automation code for wait_for_timeout and asyncio.sleep between navigation and reads; each one is a guess.

Milliseconds per page by waiting strategy 5 horizontal bars comparing sleep 200 ms (31/40 correct) with the others. Milliseconds per page by waiting strategy sleep 200 ms (31/40 correct) 421 ms sleep 500 ms (40/40) 739 ms wait_until=networkidle (40/40) 901 ms locator wait (40/40) 427 ms commit + locator (40/40) 566 ms Playwright 1.63, Python 3.14; 40 pages per strategy on a shared machine. Waiting for the element was as fast as the broken sleep, and always right.

2. Wait for the element you need

A locator describes an element; wait_for() waits until it is in the requested state, polling efficiently inside the browser:

await page.goto(url)
price = page.locator("#price[data-ready]")
await price.wait_for(state="visible", timeout=10_000)
text = await price.text_content()

Measured: 427 ms per page and 40 of 40 correct. The selector includes the condition that makes the content final — here a data-ready attribute the page sets after the API call — rather than just the element's existence, because #price existed from the start with the text loading. Choosing that condition is the real work: an attribute, a class, the disappearance of a spinner, or specific text. Playwright's actions (click, fill) and assertions auto-wait for actionability, so explicit wait_for is needed mainly before reads.

Verify: each read in the automation is preceded by a wait on a selector that only matches the final state.

3. Use web-first assertions and expect for content

When the condition is about content rather than presence, Playwright's expect retries until the assertion holds or the timeout expires:

from playwright.async_api import expect

await page.goto(url)
await expect(page.locator("#price")).not_to_have_text("loading", timeout=10_000)
await expect(page.locator(".results li")).to_have_count(20)
await expect(page.locator("#status")).to_have_text("Complete")

expect is not limited to tests: in scrapers and automations it expresses "wait until the page says X" directly, without hand-written polling loops. The timeout turns a page that never reaches the state into an AssertionError with a description of what was expected and what was found — far easier to debug than a sleep followed by a wrong value.

Verify: content-dependent waits use expect with an explicit timeout, and failures report the expected and actual values.

What should this step wait for? A decision on What tells you the page is ready with 4 outcomes. What should this step wait for? What tells you the page is ready? an element in its final state locator.wait_for() 427 ms, 40/40 specific text or count expect(...).to_have_text retries until true a particular API call finished page.expect_response(...) waits on that call nothing specific find a signal; avoid networkidle and sleeps 901 ms / wrong Wait for the condition, not for time.

4. Avoid networkidle as a readiness signal

wait_until="networkidle" waits until there have been no network connections for 500 ms — a heuristic that knows nothing about the content:

await page.goto(url, wait_until="networkidle")        # 901 ms per page
await page.goto(url, wait_until="commit")             # returns as soon as the response starts
await page.locator("#price[data-ready]").wait_for()   # 566 ms combined

Measured: networkidle was correct but took 901 ms per page — every page paid the 500 ms quiet period after its last request, even when the price had appeared long before. On real sites it is worse in the other direction: analytics beacons, long polling and websockets keep the network busy, so networkidle waits until the timeout or never settles. Navigating with wait_until="commit" and then waiting on the element measured 566 ms here — slower, not faster, than the default goto followed by the same locator wait, so returning from navigation early bought nothing on this page. The default goto plus a locator wait remained the fastest correct option; measure before assuming an earlier wait_until helps.

Verify: no navigation in production code uses networkidle as its readiness condition.

Correctness and cost of each waiting strategy A grid of 5 rows by 3 columns. Correctness and cost of each waiting strategy strategy correct why it costs what it costs sleep 200 ms 31 of 40 too short whenever the page was slower sleep 500 ms 40 of 40 waits the full 500 ms even when ready sooner networkidle 40 of 40 adds a 500 ms quiet period after the last request locator on final state 40 of 40 returns as soon as the state appears commit + locator 40 of 40 no faster than the default goto here Only waits on the actual condition were both correct and fast.

5. Wait for the response that carries the data

When the data comes from an API call the page makes, wait for that response directly — and read the data from it, skipping the DOM entirely:

async with page.expect_response(lambda r: "/api/price/" in r.url and r.ok) as response_info:
    await page.goto(url)
response = await response_info.value
data = await response.json()                          # {"price": "1.50"}

This is both the most precise readiness signal and often the most robust extraction: the JSON is structured, while the rendered text may be formatted, localized or split across elements. The context manager registers the waiter before navigation, so a fast response is not missed. If the data is all you need, consider whether the page is needed at all — calling the API directly with an HTTP client, as in Async HTTP Clients & Servers, is an order of magnitude cheaper than rendering, where the site's terms allow it.

Verify: for pages whose data comes from an API, the automation reads the API response rather than parsing rendered text.

Verification

Waiting is correct and efficient when:

  • No fixed sleeps sit between navigation and reads.
  • Each read waits on a selector or assertion that matches only the final state.
  • networkidle is not used as a readiness condition.
  • Data from API calls is read from the response when that is available.

Diagnostic Hook: log, per page type, the time between goto returning and the readiness wait completing. A distribution with a hard floor at a round number is a hidden sleep; a long tail that hits the timeout identifies pages whose readiness signal never appears — usually a changed selector on the target site.

Pitfalls & edge cases

  • Short sleeps. Measured: 9 wrong reads in 40 at 200 ms, silently.
  • Long sleeps. Measured: correct, but 739 ms per page against 427 ms for a locator.
  • Selectors that match the loading state. #price existed while it said loading.
  • networkidle. Measured: 901 ms per page here; on busy sites it may never settle.

Frequently Asked Questions

How do I wait for an element to load in Playwright Python?

Use a locator whose selector matches the element only in its final state and call await locator.wait_for(), or use expect(locator).to_have_text(...). On a page rendering after 50–400 ms this was correct 40 of 40 times at 427 ms per page.

Why should I not use sleep or wait_for_timeout in Playwright?

A fixed delay is either too short — a 200 ms sleep read the wrong value 9 times in 40 — or wasteful: 500 ms was correct but cost 739 ms per page against 427 ms for a locator wait.

Is waitUntil networkidle a good way to wait in Playwright?

Rarely. It waits for 500 ms without network activity, which took 901 ms per page in testing and may never happen on sites with analytics or long polling. Wait for a specific element or response instead.

How do I wait for an API call in Playwright?

Use async with page.expect_response(predicate) as info around the action, then await info.value and read response.json().