Post

Playwright wait_for_selector in Python: Waiting for Elements Reliably

Playwright wait_for_selector in Python: Waiting for Elements Reliably

Modern websites rarely deliver their content in the initial HTML response. Data tables, product listings, and search results are loaded by JavaScript after the page shell arrives. If your script reads the DOM before that JavaScript finishes, it gets empty results. Playwright for Python provides several explicit and implicit mechanisms for waiting on elements, and understanding when to use each one is the difference between a script that works reliably and one that fails on every other run. This post covers every waiting strategy Playwright offers, from the classic wait_for_selector through the modern locator-based approach and into the patterns you will actually use in production.

page.wait_for_selector — The Classic Approach

The page.wait_for_selector() method pauses execution until a CSS selector matches an element in the DOM. It returns the ElementHandle for the first matching element, or raises TimeoutError if the element never appears.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com/dashboard")

    # Wait until the table appears in the DOM
    page.wait_for_selector("table.data-grid")

    rows = page.query_selector_all("table.data-grid tr")
    print(f"Found {len(rows)} rows")

    browser.close()

You can also use it inline to get a reference and extract data immediately:

1
2
element = page.wait_for_selector("div.price-tag")
price = element.inner_text()

By default, wait_for_selector waits for the element to be both present in the DOM and visible on the page. Visibility means the element has a non-zero bounding box and is not hidden by display: none, visibility: hidden, or opacity: 0.

graph TD
    A[wait_for_selector called] --> B{Element in DOM?}
    B -- No --> C[Poll DOM<br>repeatedly]
    C --> B
    B -- Yes --> D{Element visible?}
    D -- No --> C
    D -- Yes --> E[Return ElementHandle]
    C --> F{Timeout reached?}
    F -- Yes --> G[Raise TimeoutError]
    F -- No --> B

Locator-Based Waiting: The Modern Approach

Playwright now recommends locators over wait_for_selector. A locator is a lazy reference to an element — it does not query the DOM until you act on it. The wait_for() method gives you the same waiting behavior with a cleaner API.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com/dashboard")

    table_locator = page.locator("table.data-grid")
    table_locator.wait_for(state="visible")

    row_count = page.locator("table.data-grid tr").count()
    print(f"Found {row_count} rows")

    browser.close()

The key advantage: locators retry automatically. When you call table_locator.inner_text(), Playwright waits for the element to be visible before reading. Many scripts do not need an explicit wait_for() at all.

Featurepage.wait_for_selector()locator.wait_for()
ReturnsElementHandleNone (use locator for actions)
StatusLegacy patternCurrent recommendation
Auto-retry on actionsNoYes

Wait States: attached, visible, hidden, detached

Both methods accept a state parameter with four options:

  • attached — element exists in the DOM, regardless of visibility
  • visible — element is in the DOM and visible (the default)
  • hidden — element is either absent or not visible. Use this to wait for spinners to disappear
  • detached — element has been removed from the DOM entirely
1
2
3
4
5
6
7
8
# Wait for a hidden element to exist in the DOM
page.wait_for_selector("div.modal-container", state="attached")

# Wait for a loading overlay to vanish
page.wait_for_selector("div.loading-overlay", state="hidden")

# Wait for a splash screen to be removed from the DOM
page.wait_for_selector("div.splash-screen", state="detached")
graph TD
    A[Element State] --> B[detached<br>Not in DOM]
    A --> C[attached<br>In DOM]
    C --> D[hidden<br>In DOM but not visible]
    C --> E[visible<br>In DOM and visible]

Timeout Configuration

The default timeout is 30 seconds. You can customize it at three levels:

1
2
3
4
5
6
7
8
9
# Per-call: wait up to 10 seconds for this element
page.wait_for_selector("table.data-grid", timeout=10000)

# Page-level: change default for all waits on this page
page.set_default_timeout(5000)

# Context-level: change default for all pages in this context
context = browser.new_context()
context.set_default_timeout(15000)

All values are in milliseconds. Pass timeout=0 to wait indefinitely, but be careful — a hanging script is worse than a crashed one.

Auto-Waiting: When You Do Not Need Explicit Waits

Playwright automatically waits for actionability before performing .click(), .fill(), .check(), and similar methods on locators. It checks that the element is attached, visible, stable, enabled, and not obscured.

1
2
3
4
5
6
page.goto("https://example.com/login")

# No explicit wait needed --- Playwright waits automatically
page.locator("input#username").fill("user@example.com")
page.locator("input#password").fill("password123")
page.locator("button[type='submit']").click()

For more complex form workflows — multi-step wizards, dynamically added fields, conditional inputs — see our guide to automating web form filling. You still need explicit waits when you are reading data (auto-wait does not apply to query_selector_all on the page object), waiting for an element to disappear, or waiting for a non-element condition.

page.wait_for_load_state

Waits for the page to reach a loading milestone. Three states are available:

  • domcontentloaded — HTML parsed, external resources may still be loading
  • load — HTML and all synchronous sub-resources finished (the default for goto)
  • networkidle — no network requests for 500ms
1
2
3
4
page.goto("https://example.com/dashboard")
page.wait_for_load_state("networkidle")

rows = page.query_selector_all("table.data-grid tr")

Be cautious with networkidle. Pages with analytics beacons, WebSocket connections, or polling scripts may never reach network idle, causing a timeout.

Browser automation turns repetitive tasks into reliable scripts.
Browser automation turns repetitive tasks into reliable scripts. Photo by ThisIsEngineering / Pexels

page.wait_for_url

Wait for the URL to change after navigation, form submission, or a redirect:

1
2
3
page.locator("button[type='submit']").click()
page.wait_for_url("**/dashboard")
print(page.url)  # https://example.com/dashboard

Supports glob patterns (**/products/*), exact strings, and regex (re.compile(r".*/order/\d+")).

page.expect_response: Intercept API Data

Instead of waiting for the DOM to update, intercept the API response directly:

1
2
3
4
5
6
with page.expect_response("**/api/products*") as response_info:
    page.goto("https://example.com/products")

response = response_info.value
data = response.json()
print(f"Got {len(data['items'])} products from API")

You can use a predicate for complex matching:

1
2
3
4
def is_product_api(response):
    return "/api/products" in response.url and response.status == 200

response = page.wait_for_response(is_product_api)

This is powerful for scraping because you get raw JSON without parsing the DOM.

Custom Waits with page.wait_for_function

Run arbitrary JavaScript and wait until it returns a truthy value:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Wait for at least 10 table rows
page.wait_for_function(
    "document.querySelectorAll('table.data-grid tr').length >= 10"
)

# Wait for an image to finish loading
page.wait_for_function("""
    () => {
        const img = document.querySelector('img.product-main');
        return img && img.complete && img.naturalWidth > 0;
    }
""")

# Wait for a global variable set by the app
page.wait_for_function("typeof window.__DATA__ !== 'undefined'")
data = page.evaluate("window.__DATA__")

You can pass arguments from Python:

1
2
3
4
5
6
7
8
min_count = 5
page.wait_for_function(
    """(minCount) => {
        const rows = document.querySelectorAll('table.data-grid tr');
        return rows.length >= minCount;
    }""",
    min_count,
)

Common Patterns

Wait for a Loading Spinner to Disappear

1
2
3
4
5
page.goto("https://example.com/search?q=python")
page.locator("div.spinner").wait_for(state="hidden")

results = page.locator("div.search-result")
print(f"Found {results.count()} results")

Wait for a Data Table to Populate

1
2
3
4
5
6
7
8
page.goto("https://example.com/reports")
page.locator("table.report tbody tr").first.wait_for(state="visible")

rows = page.locator("table.report tbody tr")
for i in range(rows.count()):
    cells = rows.nth(i).locator("td")
    row_data = [cells.nth(j).inner_text() for j in range(cells.count())]
    print(row_data)

Wait for Infinite Scroll Content

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
previous_count = 0

for _ in range(10):
    current_count = page.locator("div.feed-item").count()

    if current_count == previous_count and previous_count > 0:
        break

    previous_count = current_count
    page.evaluate("window.scrollTo(0, document.body.scrollHeight)")

    try:
        page.wait_for_function(
            f"document.querySelectorAll('div.feed-item').length > {current_count}",
            timeout=5000,
        )
    except Exception:
        break

print(f"Total items: {page.locator('div.feed-item').count()}")

Error Handling: TimeoutError and Retries

When a wait times out, Playwright raises TimeoutError. Always handle it in production:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com/dashboard")

    try:
        page.wait_for_selector("table.data-grid", timeout=10000)
        rows = page.query_selector_all("table.data-grid tr")
        print(f"Found {len(rows)} rows")
    except PlaywrightTimeout:
        print("Data table did not appear within 10 seconds")
        page.screenshot(path="timeout_debug.png")

    browser.close()

A retry wrapper with increasing timeouts handles flaky pages:

1
2
3
4
5
6
7
8
9
10
11
def wait_with_retry(page, selector, max_retries=3, initial_timeout=5000):
    for attempt in range(max_retries):
        timeout = initial_timeout * (attempt + 1)
        try:
            return page.wait_for_selector(selector, timeout=timeout)
        except PlaywrightTimeout:
            if attempt < max_retries - 1:
                page.reload()
                page.wait_for_load_state("domcontentloaded")
            else:
                raise

Quick Reference

MethodWaits ForDefault Timeout
page.wait_for_selector(sel)CSS selector to match a visible element30s
locator.wait_for(state=...)Locator to reach specified state30s
page.wait_for_load_state(state)Page load milestone30s
page.wait_for_url(pattern)URL to match pattern after navigation30s
page.expect_response(pattern)Network response matching pattern30s
page.wait_for_function(js)JavaScript expression to return truthy30s

Decision Flowchart

graph TD
    A[What are you<br>waiting for?] --> B{A specific<br>element?}
    B -- Yes --> C{Need to<br>interact with it?}
    C -- Yes --> D[Use locator with<br>auto-waiting]
    C -- No --> E[Use locator.wait_for<br>or wait_for_selector]
    B -- No --> F{A page<br>load state?}
    F -- Yes --> G[Use<br>wait_for_load_state]
    F -- No --> H{A URL<br>change?}
    H -- Yes --> I[Use<br>wait_for_url]
    H -- No --> J{An API<br>response?}
    J -- Yes --> K[Use<br>expect_response]
    J -- No --> L[Use<br>wait_for_function]

If elements are hidden inside shadow DOM trees, standard selectors will not find them regardless of how long you wait — you will need to pierce the shadow root first. For a broader look at how Playwright stacks up against other frameworks, see our Playwright vs Puppeteer vs Selenium vs Scrapy mega comparison. Choose the most specific wait method for your situation. wait_for_function is the escape hatch when nothing else fits, but prefer the built-in methods when they apply — they produce clearer error messages and are easier to debug when they fail.

Contact Arman for Complex Problems
This post is licensed under CC BY 4.0 by the author.