Auto-Waiting and Explicit Waits in Playwright Python

Flaky tests are almost always waiting problems: the test acts or checks before the page is ready. Playwright for Python removes most of them with auto-waiting, so you rarely write a wait yourself. In this lesson you will see exactly what Playwright waits for before each action, which explicit waits exist for the remaining cases (navigation, elements, JavaScript conditions and network responses), how to set timeouts, and why time.sleep() does not belong in a test.

Advertisement

📖 What You’ll Learn: actionability checks (visible, stable, receives events, enabled, editable), what happens when an element never becomes actionable, force=True, goto wait_until options (commit, domcontentloaded, load, networkidle), wait_for_url, locator.wait_for with states, page.wait_for_function, expect_response and expect_request, action, navigation and assertion timeouts and how to change them, why wait_for_timeout and time.sleep are flaky, and a simple polling helper for non-page conditions.


🤔 Why Waiting Matters

A browser does many things asynchronously: it downloads files, runs JavaScript, starts animations and waits for servers. Your test code runs much faster than all of that. If the test clicks a button before it exists, or reads a text before the server has answered, the test fails, even though a real user would have seen a working page.

Older tools solve this with waits written by hand: “sleep 3 seconds”, or “wait until the element is clickable” before every step. Playwright takes a different approach:

  1. Actions auto-wait. click(), fill(), check() and the others wait until the element is ready.
  2. Assertions retry. expect(...) keeps checking until the page matches (previous lesson).
  3. Explicit waits exist for the few cases left: navigation, network responses and custom conditions.

⚙️ Auto-Waiting: The Actionability Checks

Before an action, Playwright first finds exactly one element for the locator, then checks that the element is actionable. Which checks run depends on the action:

ActionVisibleStableReceives eventsEnabledEditable
click(), dblclick(), check(), uncheck(), set_checked(), tap()YesYesYesYes–
hover(), drag_to()YesYesYes––
fill(), clear()Yes––YesYes
select_option()Yes––Yes–
screenshot()YesYes–––
press(), press_sequentially(), focus(), set_input_files(), dispatch_event()–––––

(Source: the official Playwright Python “Auto-waiting” guide.)

What each check means:

  • Visible: the element has a size, and is not visibility:hidden or display:none. An element with opacity:0 counts as visible.
  • Stable: the element kept the same position and size for two animation frames, so it is not sliding or growing.
  • Receives events: nothing else, such as an overlay or a cookie banner, covers the point Playwright will click.
  • Enabled: no disabled attribute (also through a disabled <fieldset>), and not inside aria-disabled="true".
  • Editable: enabled and not read-only.

Seeing auto-waiting work

To show auto-waiting clearly, this test builds a tiny page with page.set_content(). The button starts disabled, and a loading overlay covers it. After one second the overlay disappears; after 1.5 seconds the button becomes enabled. The test contains no wait at all:

📄 tests/test_auto_wait.py

import time

from playwright.sync_api import Page, expect

SLOW_PAGE = """
<div id="overlay" style="position:fixed; inset:0; background:#0008">Loading...</div>
<button id="save" disabled onclick="this.textContent='Saved'">Save</button>
<script>
  setTimeout(() => document.getElementById('overlay').remove(), 1000);
  setTimeout(() => document.getElementById('save').disabled = false, 1500);
</script>
"""


def test_click_waits_until_the_button_is_ready(page: Page):
    page.set_content(SLOW_PAGE)
    start = time.monotonic()

    page.get_by_role("button", name="Save").click()     # waits for: no overlay + enabled

    print(f"\nclicked after {time.monotonic() - start:.1f} s")
    expect(page.get_by_role("button")).to_have_text("Saved")
  • page.set_content(html) loads HTML directly into the page. It is handy for demonstrating one behaviour in isolation.
  • click() kept waiting until the button was enabled and nothing covered it, then clicked.
  • In our run it printed clicked after 2.0 s, a little later than 1.5 s: Playwright re-checks the element in a loop, so it acts on a check shortly after the page becomes ready, not at the exact moment it changes.
  • time.monotonic() is used only to print how long the click waited.

When an element never becomes actionable

If the checks do not pass within the action timeout, the action fails with a TimeoutError that explains what it was waiting for. Here the overlay never goes away:

📄 demos/test_blocked_click.py

from playwright.sync_api import Page

BLOCKED = """
<div id="cookie-banner" style="position:fixed; inset:0; background:#0008">We use cookies</div>
<button>Buy now</button>
"""


def test_button_covered_by_a_banner(page: Page):
    page.set_content(BLOCKED)
    page.get_by_role("button", name="Buy now").click(timeout=3_000)

⌨️ Terminal

pytest -q demos/test_blocked_click.py

🖥️ Output

E           playwright._impl._errors.TimeoutError: Locator.click: Timeout 3000ms exceeded.
E           Call log:
E             - waiting for get_by_role("button", name="Buy now")
E               - locator resolved to <button>Buy now</button>
E             - attempting click action
E               2 × waiting for element to be visible, enabled and stable
E                 - element is visible, enabled and stable
E                 - scrolling into view if needed
E                 - done scrolling
E                 - <div id="cookie-banner">We use cookies</div> intercepts pointer events
E               - retrying click action
E               - waiting 20ms
E               2 × waiting for element to be visible, enabled and stable
E                 - element is visible, enabled and stable
E                 - scrolling into view if needed
E                 - done scrolling
E                 - <div id="cookie-banner">We use cookies</div> intercepts pointer events
E               - retrying click action
E                 - waiting 100ms
E               5 × waiting for element to be visible, enabled and stable
E                 - element is visible, enabled and stable
E                 - scrolling into view if needed
E                 - done scrolling
E                 - <div id="cookie-banner">We use cookies</div> intercepts pointer events
E               - retrying click action
E                 - waiting 500ms
E               - waiting for element to be visible, enabled and stable

..\_venv0\Lib\site-packages\playwright\_impl\_connection.py:636: TimeoutError
=========================== short test summary info ===========================
FAILED demos/test_blocked_click.py::test_button_covered_by_a_banner[chromium]
1 failed, 1 warning in 3.64s
  • The call log is your best debugging tool. Here it says the <div id="cookie-banner"> intercepts pointer events, so the fix is to close the banner first, not to add a sleep.
  • timeout=3_000 limits this one action to 3 seconds. Without it, the default action timeout applies (30 seconds in pytest-playwright).

⚠️ Watch out: click(force=True) skips the “receives events” and other non-essential checks. It makes this test pass, but a real user could not click that button. Use force=True only when you understand why the check fails and it does not matter for the test.


🧭 Waiting for Navigation: wait_until

page.goto() waits for the page to load before it returns. The wait_until argument decides what “loaded” means:

wait_untilgoto() returns when…
"commit"The server’s response arrived and the document started loading
"domcontentloaded"The HTML is parsed (the DOMContentLoaded event)
"load" (default)The page and all its resources, such as images, styles and scripts, have loaded
"networkidle"No network request for 500 ms (discouraged by Playwright)

📄 tests/test_navigation.py

import time

from playwright.sync_api import Page, expect

URL = "https://sqatools.in/automation-practice-page/"


def test_wait_until_options(page: Page):
    for state in ["commit", "domcontentloaded", "load"]:
        start = time.monotonic()
        response = page.goto(URL, wait_until=state)
        print(f"\n{state:>16}: {time.monotonic() - start:.2f} s, status {response.status}")

    # Whatever wait_until you choose, check readiness with an assertion
    expect(page.get_by_role("heading", name="Text Fields")).to_be_visible()

One run printed these timings (the full run at the end of the lesson shows another; the numbers vary):

🖥️ Output

          commit: 0.14 s, status 200
domcontentloaded: 0.17 s, status 200
            load: 2.61 s, status 200
  • goto() returns the main Response, so you can check its status code.
  • The numbers change from run to run, but the order is always the same: commit is the earliest, load the latest.
  • "load" waits for every image, font and third-party script. Some sites load slow third-party resources: while preparing this course, the load event of a public demo site sometimes took longer than the 30-second navigation timeout. When that happens, use wait_until="domcontentloaded" and then wait for the element you actually need with expect().

📝 Note: Why is "networkidle" discouraged? Many modern pages never become idle: analytics, chat widgets and polling keep sending requests. Your test then waits until the timeout. Wait for something the user can see instead.

After a click that navigates: wait_for_url

goto() waits by itself. But when a click starts the navigation, wait for the new URL:

📄 tests/test_navigation.py

def test_click_then_wait_for_url(page: Page):
    page.set_content(f'<a href="{URL}">Open the practice page</a>')
    page.get_by_role("link", name="Open the practice page").click()

    page.wait_for_url("**/automation-practice-page/")     # glob pattern
    expect(page).to_have_url(URL)                       # the assertion form also waits
    expect(page).to_have_title("Automation Practice Page - SQA Tools")
  • wait_for_url() accepts an exact URL, a glob (** matches any characters, including /), a regex or a function.
  • expect(page).to_have_url(...) waits too. Prefer it when you also want the check to appear as an assertion in your test.
  • wait_for_url() also takes wait_until=, so you can return as soon as the new URL has committed.

🔍 Waiting for an Element: locator.wait_for()

Sometimes you need to wait for an element without acting on it or asserting it, for example before reading a list of values. locator.wait_for() waits for one of four states:

state=Waits until the element…
"visible" (default)is in the DOM and visible
"hidden"is not visible, or not in the DOM
"attached"is in the DOM (visible or not)
"detached"is removed from the DOM

The practice page has an auto-suggest box: typing into the search field shows matching suggestions below it.

📄 tests/test_locator_wait.py

from playwright.sync_api import Page


def test_wait_for_suggestions(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    search = page.locator("#searchBox")
    suggestions = page.locator("#suggestions div")

    search.press_sequentially("pl")
    suggestions.first.wait_for()                       # state="visible" by default
    print("\nsuggestions:", suggestions.all_inner_texts())

    search.fill("")                                    # empty input hides the box
    page.locator("#suggestions").wait_for(state="hidden")
    print("box hidden, suggestion items left:", suggestions.count())
  • all_inner_texts() does not wait; it returns whatever is in the page right now. That is why the test waits for the first suggestion before reading the list.
  • wait_for() is strict: if the locator matches several elements, it raises a strict mode violation for any state. That is why this test uses .first.
  • Typing “pl” matches only “Playwright” in the page’s suggestion list, so one item appears.
  • count() does not wait either. The page empties the box when the input is cleared, so the count is 0 after the box is hidden.

💡 Tip: If you can express the wait as an assertion, do: expect(suggestions).to_have_count(2) waits and checks in one line. Use wait_for() when you only need the page to be ready before reading data.


🧮 Waiting for a JavaScript Condition: wait_for_function

When the condition is easier to describe in JavaScript, page.wait_for_function() runs your expression in the browser again and again until it returns a truthy value:

📄 tests/test_wait_function.py

import time

from playwright.sync_api import Page


def test_wait_for_ajax_text(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    page.get_by_role("button", name="Load Success").click()

    start = time.monotonic()
    page.wait_for_function(
        "() => document.querySelector('#loader').innerText.includes('loaded')"
    )
    print(f"\nloaded after {time.monotonic() - start:.1f} s:", page.locator("#loader").inner_text())
  • The expression runs inside the page, so it can read anything JavaScript can: element text, global variables, localStorage and more.
  • By default it re-checks on every animation frame. Pass polling=500 to check every 500 ms instead.
  • Pass Python values to the function with arg=: page.wait_for_function("n => document.querySelectorAll('tr').length >= n", arg=4).

🌐 Waiting for Network Traffic: expect_response

To wait for a specific request or response, wrap the action that triggers it in a with block:

📄 tests/test_network_wait.py

from playwright.sync_api import Page


def test_wait_for_the_page_response(page: Page):
    with page.expect_response("**/automation-practice-page/") as response_info:
        page.goto("https://sqatools.in/automation-practice-page/")

    response = response_info.value
    print(f"\n{response.status} {response.request.method} {response.url}")
    assert response.ok


def test_wait_for_a_request(page: Page):
    with page.expect_request(lambda r: r.resource_type == "document") as request_info:
        page.goto("https://sqatools.in/automation-practice-page/")

    print("\nrequest:", request_info.value.method, request_info.value.resource_type)
  • Start waiting before the action: the with block registers the listener, then the action runs inside it. If the response came first, a wait started afterwards could miss it.
  • response_info.value holds the Response once the block ends.
  • The matcher can be a URL glob, a regex or a function that receives the response (or request).
  • In a real application, use this for the API call behind a button: with page.expect_response("**/api/orders") as r: page.get_by_role("button", name="Place order").click(). Module 8 covers network testing in depth.

⏱️ Timeouts: Actions, Navigation and Assertions

WhatDefaultChange it with
Actions (click, fill, …) and wait_for*30 spage.set_default_timeout() or timeout=
Navigation (goto, wait_for_url, reload, …)30 spage.set_default_navigation_timeout() or timeout=
expect(...) assertions5 sexpect.set_options(timeout=...) or timeout=

All values are in milliseconds. set_default_navigation_timeout() takes priority over set_default_timeout() for navigation methods. The same two methods also exist on the browser context.

To change them for every test, override the page fixture in conftest.py. A fixture with the same name as the plugin’s fixture can request it and adjust it:

📄 timeouts/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def page(page: Page) -> Page:
    page.set_default_timeout(10_000)               # actions and waits: 10 s
    page.set_default_navigation_timeout(45_000)    # goto, reload, wait_for_url: 45 s
    return page

📄 timeouts/test_timeouts.py

from playwright.sync_api import Page, TimeoutError


def test_default_timeout_applies(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    try:
        page.locator("#doesNotExist").click(timeout=1_000)   # timeout= overrides the default
    except TimeoutError as error:
        print("\n" + str(error).splitlines()[0])

⌨️ Terminal

pytest -q -s timeouts

🖥️ Output

Locator.click: Timeout 1000ms exceeded.
.
1 passed, 1 warning in 3.11s
  • TimeoutError is imported from playwright.sync_api; it is not Python’s built-in TimeoutError.
  • Catching it is useful in helpers (for example, “close the promo popup if it appears”), but in a normal test let it fail so you see the call log.
  • Pass timeout=0 to wait without a limit. Avoid it in tests: a stuck test then never finishes.

🚫 Hard Waits: wait_for_timeout and time.sleep

page.wait_for_timeout(3_000)   # waits 3 s, always

A fixed wait is always wrong in one direction: on a fast run it wastes time, and on a slow run (a busy CI server) it is too short and the test fails. The Playwright documentation states that wait_for_timeout() should only be used for debugging.

time.sleep() is worse in the sync API: it blocks the Python thread, so Playwright cannot process browser events during the sleep. This test proves it with a console message the page logs after 200 ms:

📄 tests/test_sleep_vs_wait.py

import time

from playwright.sync_api import Page


def test_events_are_not_processed_during_sleep(page: Page):
    messages = []
    page.on("console", lambda msg: messages.append(msg.text))
    page.evaluate("setTimeout(() => console.log('hello'), 200)")

    time.sleep(1)                           # 1 s passes, but no event is handled
    print("\nafter time.sleep:", messages)

    page.wait_for_timeout(10)               # Playwright gets control and delivers the event
    print("after wait_for_timeout:", messages)

🖥️ Output

after time.sleep: []
after wait_for_timeout: ['hello']

The message was logged during the sleep, but your page.on("console") handler only ran once Playwright had control again. The same applies to dialog handlers and other events. If you must pause while debugging, use page.wait_for_timeout(), then replace it with a real condition:

Instead of a hard wait for…Use
An element to appearexpect(locator).to_be_visible()
A text to changeexpect(locator).to_have_text("...")
A new page after a clickpage.wait_for_url(...) or expect(page).to_have_url(...)
A server call to finishwith page.expect_response(...)
A spinner to go awayexpect(spinner).to_be_hidden()

🔁 Polling a Non-Page Condition

The TypeScript test runner has expect.poll() and toPass() for retrying arbitrary code. Playwright for Python does not have them: expect() accepts only a Page, Locator or APIResponse. For a condition outside the page, such as a file appearing on disk or a record reaching a database, write a small helper:

📄 tests/helpers.py

import time
from typing import Callable


def wait_until(condition: Callable[[], bool], timeout: float = 10.0, interval: float = 0.25) -> None:
    """Call condition() until it returns True, or raise after timeout seconds."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if condition():
            return
        time.sleep(interval)
    raise AssertionError(f"Condition not met within {timeout} s")

📄 tests/test_polling.py

import threading
from pathlib import Path

from helpers import wait_until


def test_wait_for_a_file(tmp_path: Path):
    report = tmp_path / "report.csv"

    # Something else creates the file after 1 second (here: a timer thread)
    threading.Timer(1.0, report.write_text, args=["id,status\n1,passed\n"]).start()

    wait_until(report.exists, timeout=5)
    assert report.read_text().startswith("id,status")
  • This test uses no browser, so time.sleep() inside the helper is fine. Do not use this helper for page content; expect() already does it better.
  • from helpers import wait_until works because pytest adds the test’s folder (with no __init__.py) to sys.path in its default import mode.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

clicked after 2.0 s
.
suggestions: ['Playwright']
box hidden, suggestion items left: 0
.
          commit: 0.14 s, status 200

domcontentloaded: 0.17 s, status 200

            load: 1.85 s, status 200
..
200 GET https://sqatools.in/automation-practice-page/
.
request: GET document
.
after time.sleep: []
after wait_for_timeout: ['hello']
.
loaded after 3.0 s: Data loaded successfully!
..
9 passed, 1 warning in 22.57s

❓ Frequently Asked Questions

🤔 Do I need wait_for_selector in Playwright Python?

Rarely. page.wait_for_selector() still works, but the recommended style is locators: actions auto-wait, expect() waits for states, and locator.wait_for() covers the rest.

🤔 Why does my test pass locally but time out in CI?

CI machines are often slower and may have a different network. Look at the call log: it tells you what Playwright was waiting for. Fix the cause (an overlay, a slow third-party script, a missing test user) rather than raising every timeout.

🤔 What is the default timeout in Playwright Python?

With pytest-playwright, actions and navigation have a 30-second default, and expect() assertions 5 seconds. You can change each one, as shown in the timeouts section.

🤔 Is wait_until=”networkidle” a good idea for single-page apps?

Usually not. Playwright marks it as discouraged because pages with polling, analytics or websockets may never be idle. Wait for a visible result with expect() instead.

🤔 Does fill() wait for the field to be enabled?

Yes. fill() waits until the element is visible, enabled and editable. If the field stays disabled, it fails with a TimeoutError whose call log says “element is not enabled”.


🏁 Summary

  • Actions auto-wait for actionability: visible, stable, receiving events, enabled and editable, depending on the action.
  • The call log of a TimeoutError tells you exactly which check failed.
  • goto() waits for load by default; domcontentloaded helps with slow third-party resources. Avoid networkidle.
  • Use wait_for_url() after clicks that navigate, locator.wait_for() before reading data, wait_for_function() for JavaScript conditions and expect_response() for network calls.
  • Change timeouts with set_default_timeout(), set_default_navigation_timeout(), expect.set_options() or timeout=.
  • Replace wait_for_timeout() and time.sleep() with a real condition.

Next, you will capture evidence when a test fails: screenshots, videos and traces.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Leave a Comment