Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 007 / 100

Auto-waiting in Playwright: patterns, pitfalls and fixes

Why you rarely need waits.

Expona Admin6 min read

Most teams new to Playwright discover they don't need the explicit waits they're writing. Auto-waiting in Playwright: patterns, pitfalls and fixes is a topic that separates fragile test suites from reliable ones. Understanding when and how Playwright waits automatically will transform your test stability and debugging experience.

Actions wait for elements to be actionable

When you call methods like click(), fill(), press(), or selectOption(), Playwright doesn't fire immediately. Instead, it waits for the element to meet strict actionability requirements: the element must be visible, stable in position, and not obscured by other elements. This waiting happens by default, with a 30-second timeout you can configure globally or per-action.

Consider a form where a submit button is initially disabled until all validation passes:

typescript
// ❌ Old approach with explicit wait
await page.waitForSelector('button:not(:disabled)');
await page.click('button[type="submit"]');

// ✅ Playwright approach—action waits automatically
await page.click('button[type="submit"]');

The second version works because click() will retry until the button becomes enabled and actionable. Playwright checks the element's computed style, scrolls it into view if needed, and waits for any animations to settle before interacting.

In a real e-commerce scenario, you might have a checkout page where the "Complete Purchase" button becomes enabled only after the payment processor responds. The traditional pattern would nest multiple waits and checks:

typescript
// ❌ Brittle and verbose
await page.waitForFunction(() => {
  const btn = document.querySelector('button[data-test="pay"]');
  return btn && !btn.disabled;
});
await page.waitForTimeout(500); // "safety" pause
await page.click('button[data-test="pay"]');

// ✅ Playwright's auto-waiting
await page.click('button[data-test="pay"]');

Playwright's action waits include built-in stability checks. The element must not be moving, resizing, or having its display property toggled. This makes your tests more resilient to timing issues from animations, lazy-loading, or re-renders.

Assertions retry until timeout

Playwright's assertions differ fundamentally from traditional WebDriver assertions. Instead of checking once and failing, methods like expect(locator).toBeVisible() retry for the full timeout window (default 5 seconds for assertions). This retry-first approach is why your assertions rarely need surrounding waits.

Suppose you're testing a notification banner that appears after a network request completes:

typescript
// ❌ Traditional approach
await page.waitForSelector('.toast-success');
const text = await page.textContent('.toast-success');
expect(text).toBe('Account updated');

// ✅ Playwright's retrying assertion
await expect(page.locator('.toast-success')).toContainText('Account updated');

The second approach is shorter, more reliable, and easier to debug. The expect() call will retry the entire assertion—checking both visibility and text content—until the timeout. If it fails, Playwright shows you exactly what it was waiting for and what the element actually contained.

In a ride-hailing app, you might verify that a driver's status updates after they come online:

typescript
// Simulate driver coming online
await driver.updateStatus('online');

// ✅ Assertion retries, waiting for the UI to reflect the change
await expect(page.locator('[data-test="driver-status"]')).toHaveText('Available for rides');

Here, Playwright handles all the timing. The assertion will keep checking until either the status appears (test passes) or 5 seconds elapse (test fails with a detailed snapshot). You don't manually coordinate between the backend update and the UI reflection.

Retrying assertions also handle race conditions gracefully. If the DOM re-renders or the page navigates slightly, Playwright re-evaluates the locator and checks the assertion again, rather than failing immediately on a stale reference.

Remove page.waitForTimeout calls

page.waitForTimeout() (formerly page.waitForTimeout) is a code smell in modern Playwright. It's a hard sleep that blocks for a fixed duration, ignoring actual application state. Teams add it when they encounter flaky tests, but it masks the real problem and makes tests slower.

Common scenarios where teams incorrectly use waitForTimeout:

  • After triggering an action—waiting for a backend call to complete before asserting.
  • Before clicking—hoping an element finishes animating into view.
  • Between page navigations—waiting for the new page to "settle."
typescript
// ❌ Fragile and slow
await page.click('a[href="/checkout"]');
await page.waitForTimeout(2000); // Why 2 seconds? No one knows.
await expect(page).toHaveURL('/checkout');

// ✅ Use page.waitForURL instead
await page.click('a[href="/checkout"]');
await page.waitForURL('/checkout');

// ✅ Or rely on assertions
await page.click('a[href="/checkout"]');
await expect(page).toHaveURL('/checkout');

In a SaaS dashboarding tool, you might be testing a data export that triggers an async job:

typescript
// ❌ Hope-based testing
await page.click('button[aria-label="Export CSV"]');
await page.waitForTimeout(3000); // "Should be done by then"
const link = await page.locator('a:has-text("download-report.csv")');
await expect(link).toBeVisible();

// ✅ Wait for the actual indicator
await page.click('button[aria-label="Export CSV"]');
await expect(page.locator('.export-success')).toBeVisible();
// or wait for the network call
await page.waitForResponse(r => r.url().includes('/api/export'));
await expect(page.locator('a:has-text("download-report.csv")')).toBeVisible();

The second approach is faster (if the export completes in 500ms, you don't wait the full 3 seconds) and more reliable (you're checking for real application state, not arbitrary time).

Waits hide real problems

When you wrap code in explicit waits or sleeps, you're often treating a symptom, not the cause. The real issues—flaky selectors, race conditions, missing state synchronization, or poor test isolation—remain in your codebase. Over time, your test suite becomes a collection of random timeouts and workarounds.

Consider a test for a multi-step form where each step is validated server-side:

typescript
// ❌ Ignoring the real problem
await page.fill('input[name="email"]', 'user@example.com');
await page.waitForTimeout(500);
await page.click('button:has-text("Next")');
await page.waitForTimeout(1500); // "Validation takes a bit"
await expect(page.locator('[data-step="2"]')).toBeVisible();

// ✅ Wait for the actual validation response
await page.fill('input[name="email"]', 'user@example.com');
const responsePromise = page.waitForResponse(r => 
  r.url().includes('/api/validate-email') && r.status() === 200
);
await page.click('button:has-text("Next")');
await responsePromise;
await expect(page.locator('[data-step="2"]')).toBeVisible();

The first version is brittle: if the server is slow one day, the test fails. The second version is explicit: you're waiting for the exact condition that must be true before proceeding. If that condition takes 3 seconds instead of 1.5, your test adapts.

Waits also hide bugs in your application. If a button should be disabled until a form is valid, but you're adding waitForTimeout to skip over the timing issue, you're shipping a user-facing problem. Playwright's strict timeouts and clear error messages are designed to surface these issues during development.

Common mistakes

  • Wrapping actions in waitForSelector—Actions already wait. await page.click(selector) will wait for the element to be actionable.
  • Using page.waitForTimeout before assertions—Assertions retry automatically. Add the wait only if you're testing actual asynchronous state changes, like polling or webhooks.
  • Setting global timeouts too low—The default 30-second action timeout and 5-second assertion timeout are reasonable. Lowering them doesn't make tests faster; it makes them flakier.
  • Mixing page.waitForNavigation with actions incorrectly—Modern Playwright waits for navigation automatically in most cases. Only use waitForNavigation if the navigation is triggered outside the action (e.g., by JavaScript in a listener).
  • Assuming waitForTimeout solves flakiness—If a test passes with a 5-second sleep, it was never reliable. Investigate the actual timing issue: is the element selector too broad? Is there state not synchronizing? Are you testing in the wrong browser or environment?

Key takeaways

  • Playwright actions (click, fill, etc.) automatically wait for elements to be actionable—visible, enabled, and stable.
  • Assertions retry until the timeout window expires, so you rarely need waits before them.
  • Replace page.waitForTimeout() calls with targeted waits: waitForNavigation, waitForURL, waitForResponse, or application-specific selectors.
  • Waits mask real problems. If a test needs a sleep, investigate why: flaky selectors, missing synchronization, or test isolation issues.
  • Use retrying assertions and auto-waiting actions to build tests that adapt to real application timing instead of fighting against it.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles