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¶
- Python 3.9+,
pip install playwrightandplaywright install chromium. - Contexts and pages, from running Playwright with asyncio.
- Timeouts in asyncio, from Timeouts & Deadlines.
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.
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.
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.
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.
networkidleis 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.
#priceexisted while it saidloading. 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().
Related¶
- Browser Automation — up to the topic overview.
- Blocking and mocking requests in Playwright — controlling what the page loads.
- Network I/O & Protocol Handling — the section overview.