⚡ TL;DR — Quick Answer
- → Playwright automatically waits for elements to be visible, stable, and enabled before every
click(),fill(), orcheck()— no manual waits needed - → Default timeout is 30 seconds. Set it globally in
playwright.config.tsor per-action with{ timeout: 5000 } - → Seeing timeout errors? Fix your locator first — 90% of timeout failures are wrong selectors, not slow apps
If you've spent any time writing end-to-end tests with Selenium, you know the pain: a test passes 9 out of 10 times, then fails mysteriously in CI. You add an explicit wait. It passes for a week. Then it fails again. You increase the timeout. The cycle repeats until your test is littered with Thread.sleep() and WebDriverWait calls that make it slow and flaky.
Playwright was designed to end this cycle. Its auto-waiting mechanism is built into every action method — click(), fill(), check(), selectOption() — so you never need to manually wait for elements. The result: cleaner code, faster tests, and a flaky test rate that approaches zero.
This guide breaks down exactly how Playwright's auto-waiting works, what actionability checks it performs, how to configure timeouts, and what to do in the rare cases where auto-waiting isn't enough.
What Is Auto-Waiting in Playwright?
Auto-waiting is Playwright's built-in mechanism that automatically waits for elements to be ready before performing any action on them. When you write await page.getByRole('button', { name: 'Submit' }).click(), Playwright doesn't just fire a click event immediately. Instead, it runs a series of actionability checks in a retry loop until either the element is ready or the timeout expires.
This is fundamentally different from how Selenium WebDriver works. In Selenium, every action executes immediately. If the element isn't ready, you get a NoSuchElementException, ElementNotInteractableException, or worse — a silent click on the wrong element. To avoid this, Selenium forces you to wrap every interaction in explicit waits:
// You must manually wait before EVERY interaction WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); // Wait for element to be clickable, then click WebElement submitBtn = wait.until( ExpectedConditions.elementToBeClickable( By.cssSelector("button[type='submit']") ) ); submitBtn.click(); // Wait for result text to appear WebElement result = wait.until( ExpectedConditions.visibilityOfElementLocated( By.id("success-message") ) ); assertEquals("Saved!", result.getText());
Compare that to the equivalent Playwright code:
// No waits needed — Playwright handles it all await page.getByRole('button', { name: 'Submit' }).click(); await expect(page.getByText('Saved!')).toBeVisible();
Two lines vs. twelve. Zero manual waits vs. three. And the Playwright version is more reliable because it checks six actionability conditions, not just "clickable."
Key insight: Selenium's implicit waits only check if an element exists in the DOM. Playwright's auto-waiting checks if the element is actionable — visible, stable, enabled, and not obscured. This is why "element found but not interactable" errors don't exist in Playwright.
Actionability Checks: What Playwright Waits For
Before performing any action, Playwright runs through a specific set of actionability checks. Different actions require different checks. Here are all six, and what each one means:
1. Attached
The element must be present in the DOM. If a component hasn't rendered yet, Playwright waits. If an element is removed and re-added (common in React/Vue re-renders), Playwright detects this and re-queries the locator automatically.
2. Visible
The element must have non-zero bounding box and no visibility: hidden or display: none. An element inside a collapsed accordion, behind a modal overlay, or with opacity: 0 is considered not visible. Playwright waits until CSS transitions complete and the element becomes truly visible.
3. Stable
The element must have stopped moving. Playwright compares the element's bounding box across two consecutive animation frames. If the position or size changed, it waits until the element is still. This prevents clicking a button that's mid-animation — a common source of flakiness in tests that interact with animated UIs.
4. Enabled
The element must not have a disabled attribute. For buttons, inputs, and selects, Playwright checks whether the HTML disabled property is set. If a form submission button is disabled while an API call is in progress, Playwright waits until it becomes enabled.
5. Receives Events
The element must not be obscured by another element at the action point. Playwright performs a hit-test: it checks whether a document.elementFromPoint() call at the center of the element returns that element (or a descendant). If a loading overlay, toast notification, or cookie banner is covering the button, Playwright waits until it's gone.
6. Editable
For input actions like fill() and type(), the element must be editable — not readonly and not disabled. This check is specific to text input actions and doesn't apply to clicks or checks.
Which checks apply to which actions? click() requires Attached + Visible + Stable + Enabled + Receives Events. fill() requires all six including Editable. check() and uncheck() require Attached + Visible + Stable + Enabled + Receives Events. hover() requires Attached + Visible + Stable + Receives Events (not Enabled). textContent() only requires Attached.
Auto-Waiting in Action: Before vs After
Let's look at a real-world scenario: a login form that shows a loading spinner, then redirects to a dashboard. Here's how you'd handle it in Selenium vs. Playwright.
Selenium: Manual Waits Required
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By def test_login(driver): driver.get("https://app.example.com/login") wait = WebDriverWait(driver, 10) # Wait for email input to be present email = wait.until( EC.presence_of_element_located((By.ID, "email")) ) email.send_keys("user@test.com") # Wait for password input to be present password = wait.until( EC.presence_of_element_located((By.ID, "password")) ) password.send_keys("secret123") # Wait for button to be clickable (not disabled) submit = wait.until( EC.element_to_be_clickable((By.CSS_SELECTOR, "button[type='submit']")) ) submit.click() # Wait for spinner to disappear wait.until( EC.invisibility_of_element_located((By.CLASS_NAME, "spinner")) ) # Wait for dashboard URL wait.until(EC.url_contains("dashboard")) # Wait for welcome text welcome = wait.until( EC.visibility_of_element_located((By.TAG_NAME, "h1")) ) assert welcome.text == "Welcome back!"
Playwright: Zero Manual Waits
import { test, expect } from '@playwright/test'; test('login and see dashboard', async ({ page }) => { await page.goto('https://app.example.com/login'); await page.getByLabel('Email').fill('user@test.com'); await page.getByLabel('Password').fill('secret123'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(/dashboard/); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome back!'); });
The Playwright version is 30 lines shorter, has zero wait calls, and is more reliable. Each fill() auto-waits for the input to be visible, stable, enabled, and editable. The click() auto-waits for the button to be visible, enabled, and not covered by a spinner. The toHaveURL and toHaveText assertions auto-retry until the conditions are met.
Common mistake: Some developers migrating from Selenium add await page.waitForSelector() before every click() in Playwright. This is redundant — click() already waits for the element. Remove the extra wait; it adds latency without adding reliability.
Configuring Timeouts
Playwright's auto-waiting uses a default timeout of 30 seconds. You can configure five different timeout levels depending on your needs:
1. Action Timeout
Controls how long click(), fill(), and other actions wait for actionability. This is the most commonly configured timeout.
import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { // All actions wait up to 15 seconds actionTimeout: 15_000, }, });
2. Navigation Timeout
Controls how long goto(), goBack(), reload(), and other navigation methods wait for the page to load.
export default defineConfig({ use: { navigationTimeout: 30_000, // 30s for page loads actionTimeout: 10_000, // 10s for actions }, });
3. Global Timeout
Maximum time for the entire test run. If all tests combined take longer than this, the run is killed. Useful as a CI safety net.
export default defineConfig({ globalTimeout: 60 * 60 * 1000, // 1 hour max for entire suite });
4. Test Timeout
Maximum time for a single test. Defaults to 30 seconds. Increase for tests that involve complex workflows or slow APIs.
export default defineConfig({ timeout: 60_000, // 60s per test });
You can also override per-test:
test('checkout flow with payment', async ({ page }) => { test.setTimeout(120_000); // 2 minutes for this test only // ... long checkout flow });
5. Expect Timeout
Controls how long expect() assertions retry. Separate from action timeout because assertions often need different timing.
export default defineConfig({ expect: { timeout: 10_000, // Assertions retry for up to 10s }, });
Recommended defaults: For most web apps, set actionTimeout: 10_000, navigationTimeout: 30_000, timeout: 60_000 (test), and expect.timeout: 10_000. These are generous enough for slow CI environments but tight enough to fail fast when something is genuinely broken.
When Auto-Waiting Isn't Enough
Auto-waiting handles 95% of timing issues. But there are three scenarios where you need explicit waits:
1. Dynamic Content (Lazy Loading)
If content loads on scroll or after a user action that doesn't trigger navigation, you may need to wait for a specific element to appear before asserting against it.
// Scroll to trigger lazy loading await page.getByText('Load More').click(); // Wait for the new items to appear await page.waitForSelector('.product-card:nth-child(20)'); // Or better — use a locator assertion await expect(page.getByTestId('product-list').locator('.product-card')).toHaveCount(20);
2. CSS Animations and Transitions
Playwright's stability check handles most animations, but complex multi-step CSS animations can sometimes fool it. If a modal animates in with a two-phase entrance (fade + slide), you may need to wait for the animation to complete.
// Option 1: Wait for a specific visual state await page.getByRole('button', { name: 'Open modal' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); // Option 2: Disable animations entirely in tests await page.emulateMedia({ reducedMotion: 'reduce' });
3. API-Driven Content
When a page fetches data from an API and the UI depends on the response, use waitForResponse() to wait for the specific API call to complete.
// Click triggers an API call, then UI updates const responsePromise = page.waitForResponse( response => response.url().includes('/api/orders') && response.status() === 200 ); await page.getByRole('button', { name: 'Refresh orders' }).click(); await responsePromise; // Now the data is loaded — assert await expect(page.getByTestId('order-count')).toHaveText('42 orders');
waitForSelector vs Locator Auto-Wait
Playwright has two APIs for waiting on elements: the older page.waitForSelector() and the modern Locator API. The modern approach is always preferred.
The Old Way: waitForSelector
// Wait for element, get handle, then interact const handle = await page.waitForSelector('.submit-btn'); await handle.click(); // Or wait then query await page.waitForSelector('#results-table'); const text = await page.textContent('#results-table td:first-child');
The Modern Way: Locators
// Locator auto-waits on every action await page.getByRole('button', { name: 'Submit' }).click(); // Assertion auto-retries until true await expect( page.getByRole('table').locator('td').first() ).toHaveText('Expected value');
Why locators are better:
- No stale element errors. Locators re-query the DOM on every action.
waitForSelectorreturns a handle that can become stale if React re-renders the component. - Built-in actionability. Locator actions run all six actionability checks.
waitForSelectoronly checks attached + visible. - Chainable and composable.
page.getByRole('list').getByText('Item 3')reads naturally and auto-waits at each step. - Better error messages. Locator timeouts tell you exactly which check failed: "element is not visible" vs. "timeout exceeded."
When waitForSelector is still useful: Use page.waitForSelector('.element', { state: 'detached' }) to wait for an element to be removed from the DOM. Locators don't have a built-in "wait for removal" action — though you can use await expect(locator).toBeHidden() for similar effect.
Waiting for Network Activity
Auto-waiting covers DOM interactions, but sometimes you need to wait for network requests — API calls, file downloads, or WebSocket messages. Playwright provides three tools for this:
waitForResponse
Wait for a specific API response before continuing. This is the most commonly needed network wait.
// Wait for the search API to respond const searchResponse = page.waitForResponse( resp => resp.url().includes('/api/search') && resp.status() === 200 ); await page.getByPlaceholder('Search...').fill('playwright'); const response = await searchResponse; // Parse the response data if needed const data = await response.json(); expect(data.results.length).toBeGreaterThan(0);
waitForRequest
Wait for a request to be sent (not necessarily responded to). Useful for verifying that analytics events or tracking pixels fire.
// Verify GA4 event fires on button click const analyticsRequest = page.waitForRequest( req => req.url().includes('google-analytics.com/g/collect') ); await page.getByRole('button', { name: 'Add to cart' }).click(); const request = await analyticsRequest; expect(request.url()).toContain('en=add_to_cart');
page.route — Intercepting and Mocking
Mock API responses entirely to remove network dependency from your tests. This makes tests faster and deterministic.
// Mock the products API to return controlled data await page.route('**/api/products', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 1, name: 'Widget A', price: 29.99 }, { id: 2, name: 'Widget B', price: 49.99 }, ]), }); }); await page.goto('/products'); await expect(page.getByText('Widget A')).toBeVisible(); await expect(page.getByText('$29.99')).toBeVisible();
Waiting for Page State
Playwright's waitForLoadState() lets you wait for specific page lifecycle events. There are three states:
'load'
Waits for the window.onload event. All resources (images, stylesheets, iframes) have finished loading. This is the default state page.goto() waits for.
'domcontentloaded'
Waits for the DOMContentLoaded event. The HTML is parsed and DOM is ready, but images and stylesheets may still be loading. Faster than 'load', useful when you don't need images.
'networkidle'
Waits until there are no network connections for at least 500ms. Useful for SPAs where the page technically "loads" but then fetches data via XHR/fetch.
// Navigate and wait for all resources await page.goto('https://app.example.com'); // waits for 'load' by default // Navigate faster — don't wait for images await page.goto('https://app.example.com', { waitUntil: 'domcontentloaded' }); // Wait for SPA to finish all API calls await page.goto('https://spa.example.com'); await page.waitForLoadState('networkidle'); // After a click that triggers a soft navigation in an SPA await page.getByRole('link', { name: 'Dashboard' }).click(); await page.waitForLoadState('networkidle');
Avoid networkidle as a default. It's fragile with apps that use long-polling, WebSockets, or background analytics pings. Those connections never "idle," so networkidle times out. Use it only when you specifically need all API calls to complete. Prefer waitForResponse() targeting the specific API you care about.
Anti-Patterns: Manual Waits You Should Remove
If you have any of these in your Playwright tests, they are actively making your suite worse. Remove them.
1. page.waitForTimeout() — The Silent Killer
await page.waitForTimeout(3000);await page.waitForTimeout(5000);await page.waitForTimeout(1000);
await expect(locator).toBeVisible();await page.waitForResponse(pred);await expect(page).toHaveURL(/path/);
Hardcoded waits are always wrong. If 3 seconds is enough on your machine, it might not be in CI. If you increase it to 10 seconds, every test run pays the penalty even when the app responds in 200ms. Replace every waitForTimeout with a condition-based wait.
2. Sleep / Delay Before Assertions
// BAD: sleeping to "let the page settle" await page.getByRole('button', { name: 'Save' }).click(); await page.waitForTimeout(2000); // hope it's done by now const text = await page.textContent('.status'); expect(text).toBe('Saved'); // GOOD: let the assertion auto-retry await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Saved')).toBeVisible();
3. Polling for State Changes
// BAD: custom polling loop let attempts = 0; while (attempts < 10) { const text = await page.textContent('.status'); if (text === 'Complete') break; await page.waitForTimeout(500); attempts++; } // GOOD: one-liner with auto-retry await expect(page.getByText('Complete')).toBeVisible({ timeout: 5000 });
Search your codebase: Run grep -rn "waitForTimeout" tests/ right now. Every match is a bug waiting to happen. Replace each one with a condition-based wait. Your CI pipeline will thank you. See our best practices guide for more patterns to eliminate.
How Claude AI Eliminates Wait-Related Bugs
Even with auto-waiting, teams migrating from Selenium often carry over bad habits — redundant waitForSelector calls, hardcoded timeouts, manual polling loops. Claude AI can detect and fix these patterns automatically.
In the Playwright + Claude AI & MCP Server course, you'll learn how to:
- Scan existing test suites for anti-patterns like
waitForTimeout,sleep, and redundant waits - Generate wait-free Playwright tests from natural language descriptions using Claude AI
- Auto-fix flaky tests by having Claude analyze failure traces and suggest condition-based replacements
- Migrate Selenium suites to Playwright with AI-powered code translation that removes all manual waits
- Use MCP Server to connect Claude directly to your Playwright project for real-time test generation
// You tell Claude: "This Playwright test is flaky in CI. It uses waitForTimeout in 3 places. Refactor it to use auto-waiting and web-first assertions instead." // Claude analyzes the test, identifies each hardcoded wait, // and replaces them with the correct condition-based alternative // — waitForResponse, expect().toBeVisible(), or simply // removing the redundant wait entirely.
Frequently Asked Questions
What is auto-waiting in Playwright?
Auto-waiting is Playwright's built-in mechanism that automatically waits for elements to satisfy actionability checks (attached, visible, stable, enabled, receives events, editable) before performing any action. You never need to add manual waits before click(), fill(), or other interactions — Playwright handles all the timing automatically.
How does Playwright auto-waiting differ from Selenium waits?
Selenium requires you to manually add WebDriverWait or implicit waits before every interaction. If you forget one, the test is flaky. Playwright's auto-waiting is built into every action method, so there's nothing to forget. Additionally, Playwright checks six actionability conditions (not just "element exists") and handles DOM re-renders automatically through its locator system. Read the full Playwright vs Selenium comparison.
What are Playwright actionability checks?
Playwright performs six checks before executing an action: (1) Attached — element is in the DOM, (2) Visible — element has non-zero size and isn't hidden, (3) Stable — element has stopped animating, (4) Enabled — not disabled, (5) Receives Events — not obscured by overlays, (6) Editable — not readonly (for input actions only). Different actions require different subsets of these checks.
When should I use waitForSelector in Playwright?
Rarely. Modern Playwright code should use locators instead, which auto-wait on every action. Use waitForSelector only for edge cases: waiting for an element to be removed from the DOM (state: 'detached'), or waiting for a hidden element before it appears. For most use cases, await expect(locator).toBeVisible() is the better alternative.
How do I fix timeout errors in Playwright?
Timeout errors mean auto-waiting exceeded the configured timeout. To fix: (1) verify your locator is correct using page.pause() or codegen, (2) check the element actually exists on the page, (3) look for iframes — use page.frameLocator(), (4) check for overlays blocking the element, (5) use Trace Viewer to see what happened. Only increase the timeout as a last resort — the real fix is usually a wrong locator or an app bug.
