The single biggest reason Playwright tests are stable is its auto-waiting — every action and every web-first assertion already retries until the page is in the right state. The corollary is that 95% of the explicit-wait code you'd write in Selenium is dead weight in Playwright. The remaining 5% is real, though, and using the wrong waiting primitive is one of the few ways to introduce flake into an otherwise clean suite. This lesson is about which waits the framework gives you for free, the small set of explicit waits that are genuinely needed, and the one anti-pattern (waitForTimeout) you should never reach for outside of debugging.
Auto-wait — the default behaviour
Before every action, Playwright runs an actionability checklist on the target element:
- Attached to the DOM
- Visible (not
display:none, not zero-sized) - Stable (not animating)
- Enabled (not disabled)
- Editable (for inputs)
- Receives events (not covered by another element)
If any check fails, Playwright keeps retrying for up to the action timeout (30 seconds by default) before giving up. So you can write:
await page.getByRole("button", { name: "Submit" }).click();…and even if the button is mid-fade-in animation, or briefly disabled while a previous request finishes, the click will land at the right moment. No waitForElement, no waitForEnabled, no sleep.
The same idea extends to web-first assertions. expect(locator).toHaveText('Saved') retries until the element exists, is visible, and has the expected text — or the assertion timeout (5 seconds by default) fires. Both layers of auto-wait combined are why Playwright tests look so terse compared to Selenium.
When you actually need an explicit wait
Auto-wait covers actions and assertions on DOM elements. The cases where you need to wait for something else fall into three buckets:
- Wait for a network response — typically when an action triggers an API call whose result you need before asserting.
- Wait for a specific element state — when you want to ensure a loader has disappeared before continuing.
- Wait for a custom condition — anything that isn't a locator or a network event (a global JS variable, a window prop, a custom event).
Each has a built-in API.
page.waitForResponse() — wait for an API call
The pattern: arm the waiter before the action, then await both:
const responsePromise = page.waitForResponse("**/api/products");
await page.getByRole("button", { name: "Load products" }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.length).toBeGreaterThan(0);waitForResponse accepts a glob, regex, full URL, or predicate function:
// Predicate — only respond to a specific request
const response = await page.waitForResponse(
res => res.url().includes("/api/orders") && res.status() === 201
);Use this when you want to assert on the response itself — status, JSON body, headers. If you only need the side effects (the UI update), the web-first assertion against the resulting DOM usually does the job without waitForResponse.
locator.waitFor() — wait for an element state
When you need to wait for an element to become hidden (a loading spinner disappearing) or attached (a tab finishing its lazy-load), use locator.waitFor:
// Wait for a loader to vanish before reading the table
await page.getByTestId("loader").waitFor({ state: "hidden" });
await expect(page.getByRole("table")).toBeVisible();
// Wait for an element to attach (e.g., lazy-loaded section)
await page.getByTestId("recommendations").waitFor({ state: "attached" });The state options: 'attached' (in DOM, not necessarily visible), 'detached' (removed from DOM), 'visible', 'hidden'. Most of the time you don't need this — expect(locator).toBeVisible() covers the visibility case and reads more naturally. Reach for waitFor({ state: 'hidden' }) when "the spinner is gone" is the precondition for a downstream assertion.
page.waitForLoadState() — page lifecycle
When you need to know "all subresources have loaded" or "the network is quiet," use waitForLoadState:
await page.goto("/dashboard");
await page.waitForLoadState("networkidle"); // wait for 500ms of network silence
await expect(page.getByText("Welcome")).toBeVisible();Options: 'load', 'domcontentloaded', 'networkidle'. Same semantics as the waitUntil option on goto. As mentioned in the previous lesson, 'networkidle' is flaky on apps with polling or analytics — use it sparingly, and prefer asserting on a specific UI element instead.
page.waitForFunction() — wait for a custom condition
Sometimes the thing you're waiting for isn't a DOM node — it's a global JS variable, an analytics event, or a custom property on window:
// Wait for a custom JS condition to be true
await page.waitForFunction(() => window.appReady === true);
// With a timeout
await page.waitForFunction(
() => document.title.includes("Dashboard"),
null,
{ timeout: 10_000 }
);
// Pass arguments through to the page context
await page.waitForFunction(
(expected) => document.querySelectorAll(".product").length >= expected,
10
);The function runs inside the page (not in your test process), and Playwright re-evaluates it until it returns truthy or the timeout fires. Reserve this for genuinely custom conditions — most "wait for the page to be ready" cases are better handled by asserting on a specific UI element.
Auto-wait vs explicit wait
When does Playwright wait for you, and when do you wait yourself?
Auto-wait — Playwright handles it
page.locator(...).click() — waits for actionability before clicking
page.locator(...).fill(...) — waits for editable
expect(locator).toBeVisible() / .toHaveText() — retries until match or timeout
page.goto(...) — waits for load event by default
Covers ~95% of waiting needs in real test suites
Explicit wait — you ask for it
page.waitForResponse(url) — assert on a specific API response
locator.waitFor({ state: 'hidden' }) — spinner gone before next assertion
page.waitForFunction(() => ...) — custom JS condition
page.waitForLoadState('networkidle') — rare, flaky on busy apps
Reach for these only when the auto-wait scope doesn't cover the case
The anti-pattern: page.waitForTimeout(ms)
There is exactly one waiting API in Playwright that you should never reach for in production tests:
// ❌ Wrong — never use in real tests
await page.waitForTimeout(2000);It's a hardcoded delay. It's slow when the page is fast (you wait the full 2 seconds even if the element is ready in 50ms) and flaky when the page is slow (CI has a bad day, the page takes 2.5 seconds, the test fails). Every time it appears in a real codebase, the right fix is one of the explicit waits above:
// ❌ Sleep to "let the API finish"
await page.waitForTimeout(2000);
await expect(page.getByText("Loaded")).toBeVisible();
// ✅ Auto-retry — finishes as soon as the text appears, no longer than necessary
await expect(page.getByText("Loaded")).toBeVisible();
// ❌ Sleep to "let the spinner vanish"
await page.waitForTimeout(1000);
await page.getByRole("button", { name: "Save" }).click();
// ✅ Wait explicitly for the spinner state
await page.getByTestId("loader").waitFor({ state: "hidden" });
await page.getByRole("button", { name: "Save" }).click();The one acceptable use of waitForTimeout is debugging only — and even then, await page.pause() opens the inspector and is strictly better.
Default timeouts and how to override them
Playwright has three timeout layers:
// playwright.config.ts
export default defineConfig({
timeout: 30_000, // per-test timeout (whole test must finish in 30s)
expect: { timeout: 5_000 }, // assertion timeout
use: { actionTimeout: 0 }, // per-action; 0 means "use timeout"
});Per-test-case overrides exist for the rare slow case:
test("long export job", async ({ page }) => {
test.setTimeout(120_000); // this test gets 2 minutes
// ...
await expect(page.getByText("Export ready")).toBeVisible({ timeout: 90_000 });
});Bump timeouts for individual cases that genuinely need it; never raise the global timeout to mask flake elsewhere — that just makes failures slower, not less frequent.
Coming from Cypress?
The mappings:
- Cypress auto-retries
cy.getand.shouldfor up todefaultCommandTimeout. Playwright auto-retries actions and web-first assertions for up toactionTimeout/expect.timeout. Same idea, slightly different layers. cy.intercept('/api/x').as('xx'); cy.wait('@xx')→await page.waitForResponse('/api/x')(no aliasing needed).cy.get('.spinner').should('not.exist')→await page.getByTestId('spinner').waitFor({ state: 'hidden' })(orawait expect(spinner).toBeHidden()).cy.wait(2000)→ there is no equivalent worth using. Same anti-pattern in both frameworks.
If your Cypress tests are sprinkled with cy.wait(N) calls, the migration is also a chance to delete most of them — expect(...).to... retries handle the same scenarios with no fixed delay.
⚠️ Common mistakes
- Using
page.waitForTimeoutto "stabilise" a flaky test. It's the single fastest way to make a flaky test slower without making it less flaky. Diagnose what you're actually waiting for — a network response, an element state, a route — and use the explicit wait that matches. - Calling
waitForLoadState('networkidle')on every page load. Most modern apps have analytics, polling, or WebSockets that keep the network busy forever, so the wait either times out or hangs. Stick with'load'(the default) and assert on a UI element instead. - Forgetting to arm
waitForResponsebefore the click. If you click first and thenawait page.waitForResponse(...), you may miss the response (it can already have completed). Always set up the waiter first, in aPromise.allor by saving the promise to a variable, then trigger the action.
🎯 Practice task
Build a wait-aware spec that demonstrates each pattern. 25-30 minutes.
-
Use Sauce Demo (
baseURL: "https://www.saucedemo.com") and log in viabeforeEach. -
Create
tests/waits.spec.tswith four tests, each demonstrating a different wait pattern:import { test, expect } from "@playwright/test"; test.describe("Waiting strategies", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("secret_sauce"); await page.getByRole("button", { name: "Login" }).click(); await expect(page).toHaveURL(/inventory/); }); test("auto-wait — no explicit wait needed", async ({ page }) => { // Click Add to cart, assert badge — Playwright auto-waits await page .locator(".inventory_item") .first() .getByRole("button", { name: "Add to cart" }) .click(); await expect(page.locator(".shopping_cart_badge")).toHaveText("1"); }); test("waitForResponse — wait for an API call", async ({ page }) => { // Sauce Demo doesn't have a public API the inventory page calls, // so we use the static asset request as a proxy. const responsePromise = page.waitForResponse(/inventory\.html$/); await page.reload(); const response = await responsePromise; expect(response.status()).toBe(200); }); test("locator.waitFor — wait for an element to be hidden", async ({ page }) => { // Open the burger menu await page.getByRole("button", { name: "Open Menu" }).click(); const sidebar = page.locator(".bm-menu-wrap"); await expect(sidebar).toBeVisible(); // Close it; wait for the sidebar to be hidden await page.getByRole("button", { name: "Close Menu" }).click(); await sidebar.waitFor({ state: "hidden" }); }); test("waitForFunction — wait for a custom JS condition", async ({ page }) => { // Wait until at least 6 inventory items have been rendered await page.waitForFunction( () => document.querySelectorAll(".inventory_item").length >= 6 ); await expect(page.locator(".inventory_item")).toHaveCount(6); }); }); -
Run the spec across all three browsers. All four should pass, and you'll notice the
waitForResponseandwaitForFunctiontests don't add any meaningful delay — they finish as soon as their conditions hold. -
Demonstrate the anti-pattern. Add a fifth test:
test("anti-pattern — waitForTimeout", async ({ page }) => { await page.waitForTimeout(3000); // ⚠️ NEVER do this in real tests await expect(page.locator(".inventory_item")).toHaveCount(6); });Run it. The test passes — but it's exactly 3 seconds slower than it needs to be, every run. Now imagine 50 of these in a suite: 2.5 minutes of dead air. Delete the line. Re-run. Same assertion, no delay.
-
Stretch: find a real app (your own or any public one) that calls an API on page load. Write a test that uses
waitForResponseto capture the response, parse the JSON, and assert on the data structure (e.g.,expect(data).toHaveProperty('users')). This is the bridge to Chapter 4, where every API-mocking and response-modification pattern builds on this samewaitForResponseprimitive.
You now know exactly which waits the framework gives you for free, which explicit ones to reach for, and the one to never write. The next lesson opens up multi-tab and popup flows — territory where Playwright's architecture genuinely beats every other in-browser framework.