07 · Waiting & Auto-Wait Philosophy¶
The problem auto-waiting solves¶
A page is rarely "done" the instant goto() returns. A button might be in
the DOM but not yet interactive because a JavaScript bundle hasn't finished
attaching its click handler; an element might exist but sit behind a loading
overlay; a list might render an empty state for one frame before the real
data arrives. In tools without auto-waiting, this class of timing gap is
usually patched over with a hardcoded sleep:
# the old, brittle way — don't do this
import time
page.click("#submit")
time.sleep(2) # "hope 2 seconds is enough"
page.click("#confirm")
# passes locally, fails intermittently in CI when the machine
# is slower, or wastes 2 real seconds on every run when it
# would have been ready in 200ms
This either wastes time (waiting longer than necessary, multiplied across thousands of test runs) or is still too short under load (CI runners are often slower than a developer's laptop), producing exactly the flaky, "pass on retry" tests that erode trust in a suite.
What Playwright checks before acting¶
Before most actions (click, fill, check, hover, and others),
Playwright runs a sequence of actionability checks on the target element
and retries the whole action until every check passes or the timeout
(30 seconds by default) elapses:
| Check | Meaning |
|---|---|
| Attached | The element exists in the DOM |
| Visible | Has non-empty bounding box, no visibility: hidden / display: none |
| Stable | Not currently mid-animation/transition (its bounding box is unchanged across two consecutive frames) |
| Receives events | Nothing else (a modal, an overlay, a tooltip) is on top of it at the point of interaction |
| Enabled | Not disabled (for click, fill, check, etc.) |
| Editable | Not readonly (for fill specifically) |
# no output on success — Playwright silently retries the
# actionability checks (often across just a handful of
# milliseconds) until Save is attached, visible, stable,
# unobstructed, and enabled, then performs the click
If the timeout is reached, the error tells you exactly which check kept failing:
TimeoutError: Timeout 30000ms exceeded.
Call log:
- waiting for get_by_role("button", name="Save")
- locator resolved to <button disabled>Save</button>
- element is not enabled
- retrying click action
That's a fundamentally different debugging experience from a plain
ElementNotInteractableException with no history — you know immediately
that the button existed and was visible, but stayed disabled the whole time,
pointing you straight at a form-validation bug rather than a selector bug.
Auto-waiting is not the same as "wait for navigation"¶
Auto-waiting covers interacting with elements already conceptually on the page. It does not automatically know to wait for a full page navigation or an async data fetch that hasn't started yet — for those, you wait on the result you actually care about:
# after an action that triggers navigation, chain the next
# locator call — Playwright will wait for it to appear on
# whatever page loads next:
page.get_by_role("link", name="Next").click()
expect(page.get_by_role("heading", name="Page 2")).to_be_visible()
# waiting for a specific element to prove data has loaded,
# instead of an arbitrary sleep or "networkidle":
page.get_by_role("button", name="Load more").click()
expect(page.locator(".item")).to_have_count(20)
This is the practical alternative to wait_until="networkidle" mentioned in
Module 3: wait for the specific piece of evidence that the state you care
about has arrived (an element count, a text value, a URL), not for network
traffic to go quiet — the former is precise and fast; the latter is a proxy
that can be wrong in both directions.
Explicit waits, when you actually need one¶
page.wait_for_selector(".spinner", state="hidden")
page.wait_for_url("**/checkout/confirmation")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1000) # last resort — a literal fixed pause
page.wait_for_function("() => window.appReady === true")
wait_for_timeout() is the direct equivalent of the old time.sleep()
pattern and should be treated the same way: a debugging aid or a genuine
last resort (e.g. waiting out a fixed client-side animation with no
observable completion signal), never a routine part of a real test.
wait_for_function() is the more honest tool for "the app sets a flag when
it's ready" — you're still waiting for a real condition, just one exposed
through custom JavaScript state instead of the DOM.
Setting timeouts¶
page.set_default_timeout(10_000) # applies to this page's actions
page.get_by_role("button", name="Save").click(timeout=5000) # overrides for one call
Lowering the default timeout in a fast, well-behaved suite makes a genuinely broken test fail fast instead of hanging for 30 seconds; raising it for one specific slow operation (a report export, a large upload) avoids loosening the default for everything else.
How It Actually Works¶
Auto-waiting is implemented as a retry loop around the whole action, run
by the Node driver, not a single up-front check. For click(), the driver
repeatedly: re-queries the element via CDP (DOM.resolveNode /
accessibility lookup), asks the browser to compute its current bounding box
and computed style, and evaluates each actionability condition against that
fresh snapshot. Only when every condition passes in the same iteration
does it dispatch the actual Input.dispatchMouseEvent click — this is why
the error message on timeout can name a specific failing check: the driver
recorded which condition was still false on the last iteration before
giving up.
The "stable" check specifically works by sampling the element's bounding
box via CDP twice, roughly one animation frame apart (using
requestAnimationFrame semantics under the hood), and comparing the two
rectangles — if they differ, the check restarts the retry loop rather than
treating one sample as good enough. This is deliberately conservative: a
button sliding into place is a common source of "clicked the right element
at the wrong coordinates" bugs in tools that don't check for motion at all.
wait_for_selector, wait_for_url, and expect(...) all reuse this same
retry-loop machinery under a different name — wait_for_url polls
Page.frameNavigated events and the current URL rather than DOM state, but
the poll-until-timeout-or-success shape is identical. wait_for_timeout()
is the one primitive in this module that opts out of that machinery
entirely: it's a literal setTimeout-style pause with no protocol queries
in between, which is exactly why it's discouraged as anything but a
last-resort escape hatch.
Exercise¶
- On
https://www.saucedemo.com/, log in, then intentionally slow the whole thing down withslow_mo=100and watch a click happen — confirm in your own observation that nothing appears to "wait" because the app is already fast; auto-waiting is invisible when there's nothing to wait for. - On the same site, click "Add to cart" for an item and immediately assert
expect(page.locator(".shopping_cart_badge")).to_have_text("1")without any explicit wait — confirm it passes reliably across several runs. - Deliberately write
page.locator(".shopping_cart_badge").text_content() == "1"as a plain Pythonassertinstead of usingexpect, run it 10 times in a loop, and see whether it ever fails intermittently on your machine (it may not on this particular fast app — the point is understanding why it's a latent risk, not necessarily reproducing a failure). - Trigger a
TimeoutErroron purpose: try to click a button using a locator string that doesn't match anything real, catch the exception, and read the call log it prints — identify which actionability check it never got past (in this case, none — it never even attached). - Use
page.wait_for_url("**/cart.html")after clicking the cart icon, and explain in your own notes why this is more precise thanwait_for_load_state("networkidle")here.