level 02 / waiting

Waiting Strategies

Auto-wait, explicit waits, network waits — and why sleep() is always wrong. Master the retry-until-actionable model that eliminates 90% of flakiness.

The problem: timing in async UIs

Every UI interaction is asynchronous. You click a button; the server responds; the DOM updates. Your test code runs faster than the browser. Without waiting, assertions see the pre-update state.

Auto-wait — the default

Playwright retries every action and assertion until it succeeds or the timeout expires. This is called auto-wait.

For actions (click, fill, selectOption):

  • Retries until the element is actionable (visible, stable, enabled, not covered)
  • Timeout configurable per action: { timeout: 5_000 }
  • Global default: actionTimeout in playwright.config.ts (default 30 s)

For assertions (expect(locator).toBeVisible()):

  • Retries until the condition holds
  • Timeout configurable per assertion
  • Global default: assertionTimeout in playwright.config.ts (default 5 s)

Never use page.waitForTimeout()

// ❌ Wrong — arbitrary sleep. Flaky on fast machines, slow on slow ones.
await page.waitForTimeout(3000);
await page.click('#submit');

// ✅ Correct — wait for the specific condition you need
await page.getByRole('button', { name: 'Submit' }).click();
// auto-wait handles the rest
Key insight: page.waitForTimeout() exists for debugging — step through a running test visually. It must never appear in committed test code. Enable the no-wait-for-timeout ESLint rule for Playwright.

Explicit waits — when you need them

Auto-wait covers element readiness. For other async conditions:

// Wait for navigation to complete
await page.waitForURL('**/inventory.html');
await page.waitForURL(/inventory/);

// Wait for load state
await page.waitForLoadState('networkidle'); // all network requests settled
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('load');

// Wait for a specific element to appear
await page.waitForSelector('[data-testid="success-toast"]');
// (prefer expect().toBeVisible() — it's the same thing with better error messages)

// Wait for a network response
const [response] = await Promise.all([
  page.waitForResponse('**/api/cart'),
  page.getByRole('button', { name: 'Add to cart' }).click(),
]);
expect(response.status()).toBe(200);

Waiting for network

// Intercept and wait for a specific API call
test('add to cart calls the cart API', async ({ page }) => {
  // Set up the wait BEFORE triggering the action
  const cartRequest = page.waitForRequest('**/api/v1/cart');

  await page.getByRole('button', { name: 'Add to cart' }).click();

  const req = await cartRequest;
  expect(req.method()).toBe('POST');
});

// Wait for response and check body
test('cart API returns updated count', async ({ page }) => {
  const [response] = await Promise.all([
    page.waitForResponse(r =>
      r.url().includes('/api/cart') && r.status() === 200
    ),
    page.getByRole('button', { name: 'Add to cart' }).click(),
  ]);

  const body = await response.json();
  expect(body.count).toBe(1);
});

Waitfor patterns reference

page.waitForURL(pattern)
Wait until URL matches (after navigation)
page.waitForLoadState(state)
Wait for load/domcontentloaded/networkidle
page.waitForSelector(sel)
Wait for element in DOM (prefer expect().toBeVisible())
page.waitForResponse(url)
Wait for HTTP response matching URL or predicate
page.waitForRequest(url)
Wait for outgoing HTTP request
page.waitForEvent(event)
Wait for a browser event (dialog, popup, download)
expect(locator).toBeVisible()
Assertion that retries — preferred over waitForSelector

Timeout configuration

// playwright.config.ts
export default defineConfig({
  use: {
    actionTimeout: 15_000,     // per action (click, fill, etc.)
    navigationTimeout: 30_000, // page.goto(), page.waitForURL()
  },
  expect: {
    timeout: 10_000,           // per expect() assertion
  },
  timeout: 60_000,             // per test (entire test body)
});

Interview questions