Playwright Wait for Element: Complete Guide (2026)

Playwright Wait for Element: Complete Guide (2026)

Playwright has built-in auto-waiting — most actions (click, fill, check) automatically wait for the element to be actionable. For explicit waiting, use locator.waitFor() or expect(locator).toBeVisible(). Avoid page.waitForSelector() in new code — the Locator API is more reliable and composable.

Key Takeaways

Playwright auto-waits by default. When you call locator.click(), Playwright waits for the element to be visible, stable, not obscured, and enabled before clicking. You don't need to add wait calls before most actions.

Use locator.waitFor() for conditional waiting. When you need to wait before reading a value or asserting something, await locator.waitFor({ state: 'visible' }) is the explicit wait API.

expect(locator).toBeVisible() is both an assertion and a wait. In test files, Playwright's expect assertions automatically retry until the condition is met or the timeout expires — they're not instant assertions.

page.waitForSelector() is the old API. It returns an ElementHandle, which is harder to work with. Use Locator-based APIs instead for all new code.

Never use page.waitForTimeout() (sleep). Hardcoded sleeps make tests slow and flaky — too short and they fail, too long and you waste CI minutes.

How Playwright Handles Waiting

Playwright's waiting model is fundamentally different from Selenium. In Selenium, you explicitly write wait logic before interacting with elements. In Playwright, auto-waiting is built into every action.

When you call locator.click(), Playwright automatically checks:

  • Element is attached to the DOM
  • Element is visible (not display: none or visibility: hidden)
  • Element is stable (not animating)
  • Element receives pointer events (not covered by another element)
  • Element is enabled (not disabled)

If any condition fails, Playwright retries until the default timeout (30 seconds) expires.

This means many tests need zero explicit waits:

// No wait needed — Playwright waits automatically
await page.locator('#submit-button').click();
await page.locator('input[name="email"]').fill('user@example.com');

But sometimes you need to wait explicitly — for assertions, reading values, or conditional logic.

Method 1: locator.waitFor()

waitFor() is the explicit wait API for Playwright Locators. It waits until the element reaches the specified state.

JavaScript:

const { chromium } = require('playwright');

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com');

const locator = page.locator('#results-table');

// Wait for element to be visible (default state)
await locator.waitFor();

// Wait for element to be visible (explicit)
await locator.waitFor({ state: 'visible' });

// Wait for element to appear in DOM (may not be visible)
await locator.waitFor({ state: 'attached' });

// Wait for element to disappear from DOM
await locator.waitFor({ state: 'detached' });

// Wait for element to be hidden (in DOM but not visible)
await locator.waitFor({ state: 'hidden' });

// Custom timeout (default: 30000ms)
await locator.waitFor({ state: 'visible', timeout: 5000 });

Python:

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")

    locator = page.locator("#results-table")

    # Wait for element to be visible
    locator.wait_for()

    # Wait for element to be visible (explicit)
    locator.wait_for(state="visible")

    # Wait for element to appear in DOM
    locator.wait_for(state="attached")

    # Wait for element to disappear
    locator.wait_for(state="detached")

    # Custom timeout
    locator.wait_for(state="visible", timeout=5000)

waitFor States Explained

State Meaning Use case
visible In DOM + visible + non-zero size Waiting for content to appear
attached In DOM (may be hidden) Waiting for element to exist
detached Not in DOM Waiting for element to be removed
hidden In DOM but not visible Waiting for loading spinner to hide

Method 2: expect Assertions (Auto-Retrying)

In test files using @playwright/test, expect assertions automatically retry until the condition is true or the timeout expires. This means assertions work as implicit waits.

// test.spec.js
const { test, expect } = require('@playwright/test');

test('form submission shows success message', async ({ page }) => {
    await page.goto('https://example.com/contact');
    await page.fill('input[name="email"]', 'test@example.com');
    await page.click('button[type="submit"]');

    // This retries automatically — not an instant assertion
    await expect(page.locator('.success-message')).toBeVisible();

    // Wait for text to appear
    await expect(page.locator('.status')).toHaveText('Submitted successfully');

    // Wait for element count
    await expect(page.locator('.result-item')).toHaveCount(5);

    // Wait for element to disappear
    await expect(page.locator('.loading-spinner')).not.toBeVisible();
});

Python (pytest-playwright):

def test_form_submission(page):
    page.goto("https://example.com/contact")
    page.fill("input[name='email']", "test@example.com")
    page.click("button[type='submit']")

    # Retries automatically with built-in timeout
    expect(page.locator(".success-message")).to_be_visible()
    expect(page.locator(".status")).to_have_text("Submitted successfully")
    expect(page.locator(".loading-spinner")).not_to_be_visible()

Useful Auto-Retrying Matchers

await expect(locator).toBeVisible()        // Is visible
await expect(locator).toBeHidden()         // Is not visible
await expect(locator).toBeEnabled()        // Not disabled
await expect(locator).toBeDisabled()       // Is disabled
await expect(locator).toHaveText('...')    // Has specific text
await expect(locator).toContainText('...') // Contains text
await expect(locator).toHaveValue('...')   // Input has value
await expect(locator).toHaveCount(n)       // Has n matching elements
await expect(locator).toHaveAttribute('attr', 'value')
await expect(page).toHaveURL('https://...')
await expect(page).toHaveTitle('...')

Method 3: page.waitForSelector() (Legacy API)

page.waitForSelector() is the older API that returns an ElementHandle. It still works but is less ergonomic than the Locator API.

// Old API — returns ElementHandle
const element = await page.waitForSelector('#results-table');
const text = await element.textContent();

// New API — returns Locator (preferred)
const locator = page.locator('#results-table');
await locator.waitFor();
const text = await locator.textContent();

Why prefer Locators:

  • Locators are lazy — they re-query on each interaction, avoiding stale element errors
  • Locators compose better: page.locator('.list').locator('.item')
  • waitFor() is more explicit about states than waitForSelector()

If you're maintaining old code with waitForSelector(), it works fine. For new code, use Locators.

Method 4: page.waitForFunction()

For complex conditions that can't be expressed with element states, waitForFunction() evaluates JavaScript in the page context until it returns truthy.

// Wait until page data is loaded (checking a global variable)
await page.waitForFunction(() => window.dataLoaded === true);

// Wait for a specific DOM condition
await page.waitForFunction(() => {
    const items = document.querySelectorAll('.product-item');
    return items.length > 0;
});

// Wait for element text to match
await page.waitForFunction(() => {
    const el = document.querySelector('#counter');
    return el && parseInt(el.textContent) >= 10;
});

// With arguments passed to browser context
await page.waitForFunction(
    (expectedCount) => document.querySelectorAll('.item').length >= expectedCount,
    5
);

Python:

# Wait for page data to be ready
page.wait_for_function("() => window.dataLoaded === true")

# Wait for element count
page.wait_for_function("() => document.querySelectorAll('.item').length >= 5")

Method 5: page.waitForResponse() and page.waitForRequest()

For AJAX-heavy apps, waiting for network events is often more reliable than waiting for DOM changes.

// Wait for a specific API response before asserting
const [response] = await Promise.all([
    page.waitForResponse(resp => resp.url().includes('/api/products') && resp.status() === 200),
    page.click('#load-products-button')
]);

// Assert the response data
const data = await response.json();
expect(data.products.length).toBeGreaterThan(0);

// Wait for any response matching a URL pattern
await page.waitForResponse('**/api/users**');

// Wait for request to be made
await page.waitForRequest('**/api/search**');

Python:

with page.expect_response(lambda resp: "/api/products" in resp.url and resp.status == 200) as response_info:
    page.click("#load-products-button")

response = response_info.value
data = response.json()
assert len(data["products"]) > 0

Method 6: page.waitForLoadState()

Wait for the page's network state after navigation.

await page.goto('https://example.com');

// Wait for DOMContentLoaded (fastest)
await page.waitForLoadState('domcontentloaded');

// Wait for load event (default — same as goto default)
await page.waitForLoadState('load');

// Wait for network to be idle (no requests for 500ms)
await page.waitForLoadState('networkidle');

When to use networkidle:

  • After triggering AJAX requests
  • Single-page apps that load data after mount
  • Pages with lazy-loaded resources

Warning: networkidle can be slow and sometimes unreliable on sites with analytics, ads, or polling. Prefer waiting for a specific element instead.

Common Patterns

Wait for Loading Spinner to Disappear

// Click action, then wait for spinner to disappear
await page.click('#submit');
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });
await expect(page.locator('.result')).toBeVisible();
await page.fill('#search-input', 'playwright testing');
await page.keyboard.press('Enter');

// Wait for results to load
await expect(page.locator('.search-results')).toBeVisible();
await expect(page.locator('.result-item')).toHaveCount(10);

Wait for Navigation After Click

// Use Promise.all to handle navigation race condition
await Promise.all([
    page.waitForURL('**/dashboard'),
    page.click('#login-button')
]);

// Or simpler with newer Playwright
await page.click('#login-button');
await page.waitForURL('**/dashboard');

Conditional Wait: Element May or May Not Appear

// Check if an element appears within 3 seconds
const hasPopup = await page.locator('.cookie-popup').isVisible().catch(() => false);
if (hasPopup) {
    await page.locator('.cookie-popup .dismiss').click();
}

// Or use waitFor with a short timeout
try {
    await page.locator('.cookie-popup').waitFor({ state: 'visible', timeout: 3000 });
    await page.locator('.cookie-popup .dismiss').click();
} catch (e) {
    // No popup appeared — continue
}

Configuring Default Timeouts

// playwright.config.js
module.exports = {
    use: {
        actionTimeout: 10000,   // Timeout for each action (click, fill, etc.)
        navigationTimeout: 30000 // Timeout for navigation
    },
    timeout: 60000 // Overall test timeout
};

Per-test timeout:

test('slow test', async ({ page }) => {
    test.setTimeout(90000); // 90 seconds for this test only
    // ...
});

Per-action timeout:

await locator.click({ timeout: 5000 }); // This click times out in 5s
await locator.waitFor({ state: 'visible', timeout: 15000 });

Playwright vs Selenium: Waiting Comparison

Feature Selenium Playwright
Default behavior No auto-waiting Auto-waits on every action
Explicit wait API WebDriverWait + EC locator.waitFor()
Test assertion waits No (need assertThat) Yes (expect auto-retries)
Network waiting Manual JS execution waitForResponse() / networkidle
Sleep time.sleep() common waitForTimeout() discouraged
Stale elements Common problem Locators re-query automatically
Setup boilerplate ~5 lines for explicit wait 0 lines for most waits

When to Use Each Method

Situation Method
Before interacting with an element None needed (auto-wait)
In a test assertion expect(locator).toBeVisible()
Waiting before reading a value locator.waitFor()
Waiting for element to disappear locator.waitFor({ state: 'detached' })
Waiting for AJAX to complete page.waitForResponse()
Waiting for complex JS condition page.waitForFunction()
After navigation page.waitForURL() or waitForLoadState()

Beyond Manual Waiting: AI-Powered Testing

Writing wait logic is one of the most tedious parts of browser automation. You need to understand which events to wait for, tune timeouts for your environment, and update wait logic whenever the application changes.

HelpMeTest handles waiting automatically. You describe what to test in plain English, and the AI generates tests with appropriate wait strategies built in.

Test that the search form shows results

Steps:
1. Go to the homepage
2. Type "playwright testing" in the search box
3. Click Search
4. Verify that at least 5 results appear

What HelpMeTest does automatically:

  • Waits for the search box to be interactive before typing
  • Waits for the network request to complete
  • Waits for results to render before asserting count
  • Retries on flaky network conditions
  • Self-heals when element selectors change

For teams spending hours debugging flaky waits and maintaining selector logic, AI-powered testing eliminates that overhead entirely.

Conclusion

Playwright's auto-waiting handles most cases without any explicit wait code. When you do need explicit waits:

  • Use locator.waitFor({ state: 'visible' }) for simple element waits
  • Use expect(locator).toBeVisible() for test assertions
  • Use page.waitForResponse() for AJAX-heavy interactions
  • Use page.waitForFunction() for custom JavaScript conditions
  • Never use page.waitForTimeout() (hardcoded sleep)

The key insight: wait for specific conditions, not arbitrary time. This makes your tests faster, more reliable, and easier to maintain.

Read more