Module 5: Auto-waiting & Flaky Tests
You already know how to write web-first assertions that retry automatically. Now let’s look at the other side of reliability: what Playwright does before it runs any action — and how to replace every remaining waitForTimeout call with something that actually works.
🎬 Video coming soon
How Playwright auto-waits before every action
Section titled “How Playwright auto-waits before every action”Every Playwright action — click(), fill(), hover(), press(), selectOption(), check(), uncheck() — performs a set of actionability checks before it actually executes. These checks happen automatically; you don’t write them.
The checks are:
| Check | What it means |
|---|---|
| Attached | The element exists in the DOM |
| Visible | The element is not hidden (display:none, visibility:hidden, opacity:0, zero size) |
| Stable | The element keeps the same bounding box across two consecutive animation frames (it has stopped moving/animating) |
| Receives events | No other element is covering the target (no overlay, modal, or tooltip blocking it) |
| Enabled | The element is not disabled |
Playwright checks all five conditions in a loop, polling every ~50 ms, until they all pass — or until the action timeout expires (default: 30 seconds).
// Playwright waits until the button is attached, visible, stable,// receives events, AND enabled — then clicks.await page.getByRole('button', { name: 'Place Order' }).click();
// You do NOT need to write this yourself:// await page.waitForSelector('button', { state: 'visible' }); // redundant// await page.waitForTimeout(500); // anti-patternWhat this means in practice:
- If the button is initially behind a loading overlay,
click()waits for the overlay to disappear. - If the button starts as
disabledand becomes enabled after a form fills,click()waits. - If the element is currently animating (sliding in, fading in),
click()waits for it to settle.
This is why most explicit waits are unnecessary with Playwright.
Remember: every action waits for attached + visible + stable + receives-events + enabled before it fires — you rarely need an explicit wait before one.
waitForTimeout is always an anti-pattern
Section titled “waitForTimeout is always an anti-pattern”page.waitForTimeout(N) does exactly one thing: it pauses the test for N milliseconds. It does not check any condition. It does not know when the app is ready. It is always wrong.
// ANTI-PATTERN — never do thisawait page.click('#submit');await page.waitForTimeout(3000); // hoping the page loaded in 3 secondsconst text = await page.locator('h1').textContent();expect(text).toContain('Confirmed');
// CORRECT — waits for the actual conditionawait page.click('#submit');await expect(page.locator('h1')).toContainText('Confirmed');Why waitForTimeout makes tests flaky:
- Your machine might be fast enough that 3000 ms is plenty. The CI server is 4× slower. The test fails in CI.
- The app gets faster in the next release. Your 3000 ms sleep now wastes 2 seconds on every run.
- The app gets slower due to a regression. Your sleep is now too short. The test flips to red.
waitForTimeout couples your test to hardware speed. Web-first assertions and smart-wait APIs couple your test to app behavior. The latter never breaks for the wrong reasons.
The only acceptable use of waitForTimeout is in debugging — temporarily slowing a test down to see what the browser is doing. Remove it before committing.
// OK temporarily, while debuggingawait page.waitForTimeout(5000); // REMOVE THIS BEFORE COMMITTING
// The comment is your reminder. Actually remove it.Remember: a fixed sleep checks nothing and ties your test to hardware speed — replace it with a wait on the actual condition.
expect auto-retrying — a reminder
Section titled “expect auto-retrying — a reminder”From Module 4: expect(locator) assertions retry automatically. This is relevant here because the most common reason developers reach for waitForTimeout is that they’re using a non-retrying assertion.
// Non-retrying — one shot, will fail on slow pagesconst text = await page.locator('.status').textContent();expect(text).toBe('Ready');
// Auto-retrying — retries until the condition passes or timeout expiresawait expect(page.locator('.status')).toHaveText('Ready');If you find yourself writing a waitForTimeout before checking text, a count, a URL, or a visibility state — replace the check with its web-first equivalent. The sleep disappears automatically.
Remember: most waitForTimeouts exist because of a non-retrying assertion — swap in a web-first expect(locator) and the sleep disappears.
waitForURL
Section titled “waitForURL”page.waitForURL() waits until the browser’s current URL matches the expected value. It accepts a string, a glob pattern, or a regular expression.
// Wait for an exact URLawait page.waitForURL('https://example.com/dashboard');
// Wait for a glob patternawait page.waitForURL('**/orders/**');
// Wait for a regex — useful for dynamic IDsawait page.waitForURL(/\/orders\/\d+/);The critical pattern: trigger then wait. Don’t wait for the URL first — the navigation may have already happened. The waitForURL call should follow the action that triggers it.
await page.getByRole('button', { name: 'Place Order' }).click();await page.waitForURL(/\/orders\/\d+/);// URL is now something like /orders/1042waitForURL vs expect(page).toHaveURL():
Both work. The difference is intent:
waitForURLis an explicit wait: “I am waiting for a navigation to happen.”expect(page).toHaveURL()is an assertion: “I am verifying the navigation happened.”
In practice they are nearly identical — toHaveURL also retries. Prefer toHaveURL in test assertions, and waitForURL in helper methods or page objects where you’re not directly asserting.
// In a test — use the assertion formawait checkoutPage.placeOrder();await expect(page).toHaveURL(/\/orders\/\d+/);
// In a page object helper — use the wait formasync placeOrder() { await this.placeOrderBtn.click(); await this.page.waitForURL(/\/orders\/\d+/);}Remember: trigger first, then wait; prefer toHaveURL in tests, waitForURL in helpers and page objects.
waitForResponse
Section titled “waitForResponse”page.waitForResponse() waits for a specific HTTP response to be received by the browser. Use it when an action triggers a network request and you need to verify the response before proceeding.
The critical pattern: Promise.all.
If you call waitForResponse after the action, you may miss the response — it could have completed before your listener was registered. Always start the listener before triggering the action, using Promise.all:
// WRONG — may miss the response if it fires before the listenerawait page.click('.load-more');const response = await page.waitForResponse('**/api/products');
// CORRECT — listener registered before the clickconst [response] = await Promise.all([ page.waitForResponse('**/api/products'), page.click('.load-more'),]);expect(response.ok()).toBeTruthy();Matching patterns: You can match by URL string, glob, or a function that receives the response object.
// Match by globconst [response] = await Promise.all([ page.waitForResponse('**/api/products**'), page.click('.load-products'),]);
// Match by function — useful for status code checksconst [response] = await Promise.all([ page.waitForResponse( (resp) => resp.url().includes('/api/products') && resp.status() === 200 ), page.click('.load-products'),]);
// Inspect the response bodyconst body = await response.json();expect(body.products).toHaveLength(10);When to use waitForResponse:
- After clicking “load more” — wait for the API response before asserting new items
- After submitting a form — wait for the POST response before asserting the confirmation message
- When testing network error handling — wait for a 4xx or 5xx response
Remember: register the listener before the action with Promise.all, or the response can fire before you’re listening.
waitForLoadState
Section titled “waitForLoadState”page.waitForLoadState() waits for the page to reach a specific load state. There are three states:
| State | When it fires |
|---|---|
'load' | The load event fired (all resources downloaded) |
'domcontentloaded' | The DOMContentLoaded event fired (HTML parsed, no images yet) |
'networkidle' | No network requests for 500 ms |
// Wait for the page to fully loadawait page.goto('/dashboard');await page.waitForLoadState('load'); // usually redundant — goto() already waits
// Wait for DOM to be parsed (faster than 'load')await page.waitForLoadState('domcontentloaded');
// Wait for network to settleawait page.waitForLoadState('networkidle');Important: page.goto() already waits for 'load' by default, so calling waitForLoadState('load') after goto() is almost always redundant.
Remember: goto() already waits for load — reach for a state only when you specifically need domcontentloaded or (rarely) networkidle.
networkidle caveats
Section titled “networkidle caveats”networkidle sounds like the safest option — “wait until everything is done.” In reality it has serious problems:
Problem 1: It may never fire. Modern SPAs make background requests continuously — analytics pings, heartbeat polling, WebSocket reconnects. If the app keeps sending requests, networkidle waits forever (until timeout).
Problem 2: It is slow. Even on quiet pages, networkidle always waits a minimum of 500 ms after the last request. Multiply this across hundreds of tests.
Problem 3: It waits for the wrong thing. You usually care about a specific response, not “all network activity.” A response from a different background request firing at the right moment could unlock networkidle before the response you actually care about has arrived.
// FRAGILE — fires when network quiets, not when your data is readyawait page.click('.load-products');await page.waitForLoadState('networkidle');await expect(page.locator('.product-card')).toHaveCount(10);
// ROBUST — fires when the specific response arrivesconst [response] = await Promise.all([ page.waitForResponse('**/api/products'), page.click('.load-products'),]);await expect(page.locator('.product-card')).toHaveCount(10);Playwright’s own docs now mark networkidle as discouraged — for exactly these reasons. If you see it in older examples or tutorials, treat it as a code smell, not a best practice.
When networkidle is acceptable:
- Loading a simple static page with no background requests
- Tests on legacy apps with no SPA routing
- As a last resort when no better signal exists — and even then, add a comment explaining why
Remember: wait for the specific response you care about, not for all network to go quiet — on an SPA networkidle may never come.
Diagnosing flaky tests
Section titled “Diagnosing flaky tests”A test is flaky when it passes sometimes and fails other times without any code change. Flakiness almost always has a root cause — find it.
Step 1: Check for sleeps
Section titled “Step 1: Check for sleeps”The most common cause. Search for waitForTimeout in the test file. Replace every instance with the appropriate web-first alternative.
// Beforeawait page.click('#submit');await page.waitForTimeout(2000);const text = await page.locator('.result').textContent();
// Afterawait page.click('#submit');await expect(page.locator('.result')).toHaveText(/success/i);Step 2: Check for non-retrying assertions
Section titled “Step 2: Check for non-retrying assertions”Look for assertions built from await-ed values instead of locators.
// Flaky — evaluates onceconst visible = await page.locator('.banner').isVisible();expect(visible).toBe(true);
// Stable — auto-retriesawait expect(page.locator('.banner')).toBeVisible();Step 3: Check for unguarded URL or response checks
Section titled “Step 3: Check for unguarded URL or response checks”// Flaky — page.url() is synchronous, no retryexpect(page.url()).toContain('/dashboard');
// Stable — auto-retriesawait expect(page).toHaveURL('/dashboard');Step 4: Check for race conditions in waitForResponse
Section titled “Step 4: Check for race conditions in waitForResponse”The most subtle cause. If waitForResponse is called after the triggering action, it can miss the response.
// Flaky — response may have arrived before the listenerawait page.click('.search');const resp = await page.waitForResponse('**/api/search');
// Stable — listener registered before the triggerconst [resp] = await Promise.all([ page.waitForResponse('**/api/search'), page.click('.search'),]);Step 5: Check for { force: true } on clicks
Section titled “Step 5: Check for { force: true } on clicks”click({ force: true }) skips all five actionability checks. It forces a click even if the element is covered, disabled, or hidden. This hides real issues.
// Code smell — why is this element not normally clickable?await page.locator('.submit-btn').click({ force: true });
// Fix: find and handle whatever is blocking the clickawait expect(page.locator('.modal')).not.toBeVisible(); // wait for modal to closeawait page.locator('.submit-btn').click();Flaky test diagnostic table
Section titled “Flaky test diagnostic table”| Symptom | Root cause | Fix |
|---|---|---|
| Passes locally, fails in CI | waitForTimeout tuned to fast machine | Replace with web-first assertion |
| Fails ~20% of the time on the same machine | Non-retrying assertion, race condition | Find the non-retrying check |
| Fails only when tests run in parallel | Shared state, test ordering dependency | Isolate test data; use test.describe.serial only if necessary |
| Fails after a deploy with no test changes | Locator is too brittle (depends on text, position) | Switch to role-based or data-testid locators |
Passes when you add a console.log | Race condition exposed by extra microtask tick | Add proper awaiting or Promise.all |
| Always fails on the same step | Real bug, not flakiness | Fix the app or fix the assertion logic |
Remember: flakiness has a root cause — walk the checklist (sleeps, non-retrying asserts, sync URL checks, missing Promise.all, { force: true }) instead of adding a sleep.
Custom action timeouts
Section titled “Custom action timeouts”By default, every action waits up to 30 seconds for actionability checks. You can override this globally or per-action.
// Per-action timeout (milliseconds)await page.getByRole('button', { name: 'Slow action' }).click({ timeout: 60_000 });
// Global action timeout in playwright.config.jsimport { defineConfig } from '@playwright/test';
export default defineConfig({ use: { actionTimeout: 10_000, // 10 seconds for all actions },});Reduce the timeout when you know something should be fast. If a button click should trigger navigation within 2 seconds but you have a 30-second timeout, a real regression may go undetected for 30 seconds before the test fails.
Remember: lower the timeout where things should be fast, so a real regression fails quickly instead of hanging for 30 s.
Smart-wait cheat-sheet
Section titled “Smart-wait cheat-sheet”| I need to wait for… | Use |
|---|---|
| An element / text / count condition | a web-first expect(locator)… (your default) |
| A navigation or redirect | expect(page).toHaveURL(…) (test) · page.waitForURL(…) (helper) |
| A specific network response | Promise.all([page.waitForResponse(…), action]) |
| The DOM parsed (not images) | page.waitForLoadState('domcontentloaded') |
| All network to go quiet (last resort) | page.waitForLoadState('networkidle') |
| A fixed delay | ❌ never waitForTimeout — debugging only |
Exercises
Section titled “Exercises”These exercises use TestMarket Lab running locally at http://localhost:3000.
Exercise 1: Replace fixed waits with web-first assertions
Section titled “Exercise 1: Replace fixed waits with web-first assertions”Refactor this test — remove every waitForTimeout and replace it with the correct web-first alternative:
// BEFORE (flaky — fixed sleeps everywhere)import { test, expect } from '@playwright/test';
test('search products', async ({ page }) => { await page.goto('/products'); await page.waitForTimeout(1500);
await page.fill('#search', 'Wireless'); await page.waitForTimeout(800);
await page.click('#apply-filters'); await page.waitForTimeout(3000);
const count = await page.locator('.product-card').count(); expect(count).toBeGreaterThan(0);
await page.waitForTimeout(500); expect(page.url()).toContain('search=Wireless');});| Replace | With |
|---|---|
waitForTimeout(1500) | Remove — fill() already auto-waits for the element |
waitForTimeout(800) | Remove — click() already auto-waits |
waitForTimeout(3000) + count() | expect(locator).not.toHaveCount(0) |
waitForTimeout(500) + page.url() | expect(page).toHaveURL(/[?&]search=Wireless/) |
// AFTER (web-first — no fixed sleeps)import { test, expect } from '@playwright/test';
test('search products', async ({ page }) => { await page.goto('/products'); await page.locator('#search').fill('Wireless'); await page.locator('#apply-filters').click();
await expect(page.locator('.product-card')).not.toHaveCount(0); await expect(page).toHaveURL(/[?&]search=Wireless/);});Exercise 2: Fix a flaky navigation test
Section titled “Exercise 2: Fix a flaky navigation test”The original flakes for two reasons: it checks the URL synchronously (no retry) after a fixed sleep, and its setup is incomplete — /checkout needs a logged-in user with an item in the cart, so a bare goto('/checkout') just bounces to the login page.
// BEFORE (flaky — and never reaches checkout)await page.goto('/checkout');await fillCheckoutForm(page);await page.click('#place-order');await page.waitForTimeout(2000);expect(page.url()).toMatch(/\/orders\/\d+/); // synchronous — no retryHere’s a self-contained, web-first rewrite:
// AFTERimport { test, expect } from '@playwright/test';
test.describe('Checkout redirect', () => { test.beforeEach(async ({ page }) => { await page.goto('/auth/login'); await page.locator('#email').fill('customer@test.io'); await page.locator('#password').fill('customer123'); await page.getByTestId('login-submit').click();
await page.goto('/products'); await page.getByTestId('add-to-cart').first().click(); await page.goto('/checkout'); });
test('placing an order redirects to the order page', async ({ page }) => { await page.locator('#shipping_name').fill('Jane Doe'); await page.locator('#shipping_address').fill('123 Main Street'); await page.locator('#shipping_city').fill('Portland'); await page.locator('#shipping_zip').fill('97201');
// The click auto-waits; toHaveURL retries until the redirect lands await page.getByTestId('place-order').click(); await expect(page).toHaveURL(/\/orders\/\d+/); });});Tasks:
- Swap
toHaveURLback forexpect(page.url()).toMatch(/\/orders\/\d+/)and run it a few times — the synchronous check is exactly what flaked. - In a page-object helper you’d write
await page.waitForURL(/\/orders\/\d+/)instead — both wait; the assertion form just doubles as the check.
Exercise 3: Auto-waiting and waitForResponse
Section titled “Exercise 3: Auto-waiting and waitForResponse”TestMarket Lab can deliberately slow its products page down with a ?delay=NNNN query param (milliseconds) — a perfect way to see auto-waiting work without a single sleep. Then capture the response with the Promise.all pattern.
import { test, expect } from '@playwright/test';
test.describe('Waiting for the real app', () => {
test('a web-first assertion waits out a slow page — no sleep needed', async ({ page }) => { // ?delay=2000 makes the server hold the response for 2s await page.goto('/products?delay=2000');
// No waitForTimeout: the assertion simply waits until the cards render await expect(page.locator('.product-card')).not.toHaveCount(0); });
test('capture the response with Promise.all', async ({ page }) => { // Listener registered BEFORE the navigation that triggers it const [response] = await Promise.all([ page.waitForResponse( (resp) => resp.url().includes('/products') && resp.request().method() === 'GET' ), page.goto('/products?search=Wireless'), ]);
expect(response.ok()).toBeTruthy(); await expect(page.locator('.product-card')).not.toHaveCount(0); });});Tasks:
- Bump the first test to
?delay=4000— it still passes with nowaitForTimeout; Playwright just waits longer. - Move the
waitForResponseto after thegoto(out ofPromise.all) — on a fast response the listener can miss it and the test hangs. That’s why you register it first.
Self-check questions
Section titled “Self-check questions”- What are the five actionability checks Playwright performs before every action?
- Why does
waitForTimeoutmake tests flaky on CI even when they pass locally? - What is the
Promise.allpattern forwaitForResponse, and why is it necessary? - What is the difference between
waitForURLandexpect(page).toHaveURL()? - Why is
networkidleunreliable for SPAs? - You see a test with
click({ force: true }). What does this tell you about the test? - How would you diagnose a test that passes locally but fails 20% of the time in CI?
- What is the action timeout default, and how do you change it per-action?
Key takeaways
Section titled “Key takeaways”- Auto-waiting is built in. Every Playwright action waits for the element to be attached, visible, stable, unblocked, and enabled. You rarely need to write explicit waits before actions.
waitForTimeoutis always wrong. It does not check any condition. Replace every sleep with a web-first assertion or a smart-wait API.Promise.allforwaitForResponse. Register the listener before the triggering action — never after.- Avoid
networkidleon SPAs. It waits for silence that may never come. Wait for a specific URL or response instead. - Flakiness has a root cause. Work through the diagnostic checklist: sleeps, non-retrying assertions, synchronous URL checks, missing
Promise.all, or{ force: true }.