Waiting Strategies — Auto-Wait vs Explicit Waits

8 min read

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:

  1. Attached to the DOM
  2. Visible (not display:none, not zero-sized)
  3. Stable (not animating)
  4. Enabled (not disabled)
  5. Editable (for inputs)
  6. 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:

  1. Wait for a network response — typically when an action triggers an API call whose result you need before asserting.
  2. Wait for a specific element state — when you want to ensure a loader has disappeared before continuing.
  3. 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.get and .should for up to defaultCommandTimeout. Playwright auto-retries actions and web-first assertions for up to actionTimeout / 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' }) (or await 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.waitForTimeout to "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 waitForResponse before the click. If you click first and then await page.waitForResponse(...), you may miss the response (it can already have completed). Always set up the waiter first, in a Promise.all or 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.

  1. Use Sauce Demo (baseURL: "https://www.saucedemo.com") and log in via beforeEach.

  2. Create tests/waits.spec.ts with 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);
      });
    });
  3. Run the spec across all three browsers. All four should pass, and you'll notice the waitForResponse and waitForFunction tests don't add any meaningful delay — they finish as soon as their conditions hold.

  4. 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.

  5. Stretch: find a real app (your own or any public one) that calls an API on page load. Write a test that uses waitForResponse to 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 same waitForResponse primitive.

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.

// tip to track lessons you complete and pick up where you left off across devices.