C.W.K.
Stream
Lesson 03 of 04 · published

Auto-Wait, Actionability, and Never Sleeping

~14 min · playwright-locators, auto-wait, actionability

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"page.waitForTimeout(2000) means 'I don't know what I'm waiting for.' Find out what you're waiting for."

The Five Actionability Checks

Before every action (click, fill, check, etc.), Playwright runs a set of actionability checks against the target element. The action waits until all of them pass — or fails with a clear message after the timeout. The checks:

  • Attached — the element is in the DOM.
  • Visible — has non-empty bounding box, not display: none or visibility: hidden.
  • Stable — not animating (its position has been steady for two consecutive frames).
  • Enabled — not disabled attribute, not in a fieldset[disabled].
  • Receives events — not occluded by another element (you'd actually be clicking through to it).

If a button is mid-fade-in, click waits for the animation to finish. If a modal is opening on top of your target, click waits for the modal — or fails if it never clears. You don't need to know which check is gating; you write await button.click() and Playwright handles it.

Why You Almost Never sleep

The historical reason to sleep(1000) in tests was to wait for some indeterminate UI state to settle. With auto-wait + web-first assertions, that's no longer needed. The right question isn't "how long should I wait?" — it's "what am I actually waiting for?"

Three common cases that lead to waitForTimeout misuse, and what to write instead:

  • "Wait for the page to load."await expect(page.getByRole('heading')).toBeVisible(). Assert the post-load state directly.
  • "Wait for the animation." → Just call the next action. Auto-wait handles the stability check.
  • "Wait for the API call."await page.waitForResponse('/api/users') if you need the response object, or assert on the resulting UI state.
Sleeping for time is guessing. Waiting for state is knowing. Auto-wait + web-first assertions cover almost every case where the old advice was 'add a sleep.' The remaining cases (waiting for a specific network call, waiting for an event) have explicit waiters.

The Explicit Waits That Are OK

There ARE legitimate explicit waits for specific events:

  • page.waitForURL(pattern) — useful for redirect chains or post-form-submit navigation.
  • page.waitForResponse(predicate) — when you need to capture a network response.
  • page.waitForRequest(predicate) — when you need to verify a specific request fires.
  • page.waitForEvent('console') — for browser-level events.
  • locator.waitFor({ state: 'detached' }) — for the rare case where 'wait until gone' isn't expressible as an assertion.

What's not OK is waitForTimeout(N). There's an actual condition you're waiting for — name it.

When Auto-Wait Doesn't Help — The Hidden Race

Auto-wait covers the element-side timing. It doesn't cover application-state races. If you click 'Submit,' the API call completes, and a new component renders — auto-wait gets you to the new component, but a follow-up click on that component is racing against the React commit. The fix is to assert the new state is visible before interacting with it:

await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('heading', { name: 'Success' })).toBeVisible();
// NOW it's safe to interact with the success view
await page.getByRole('button', { name: 'Continue' }).click();

Code

One click, all checks handled by Playwright·typescript
// Auto-wait in action — the actions wait for the element to be actionable
import { test, expect } from '@playwright/test';

test('button click auto-waits through actionability checks', async ({ page }) => {
  await page.goto('/');
  // Suppose the page has a Submit button that:
  // - renders 200ms after load (not yet attached)
  // - is disabled while form validation runs (not enabled)
  // - fades in over 150ms (not stable)

  // We don't need to wait for any of those — Playwright does.
  await page.getByRole('button', { name: 'Submit' }).click();

  // The next assertion also auto-waits until the success state appears.
  await expect(page.getByText(/saved successfully/i)).toBeVisible();
});
Don't sleep — assert·typescript
// The old anti-pattern vs the modern approach
import { test, expect } from '@playwright/test';

// ❌ The old way — sleep, hope, assert
test('waiting for load (the bad way)', async ({ page }) => {
  await page.goto('/dashboard');
  await page.waitForTimeout(3000);   // "give it time to load"
  const heading = await page.locator('h1').textContent();
  expect(heading).toBe('Dashboard');  // racing against actual render
});

// ✅ The modern way — assert what should be true
test('waiting for load (the right way)', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(
    page.getByRole('heading', { name: 'Dashboard' })
  ).toBeVisible();
});
Explicit waits — the legitimate ones·typescript
// Legitimate explicit waits — when there's no DOM state to assert against
import { test, expect } from '@playwright/test';

test('waits for the API response to verify network behavior', async ({ page }) => {
  await page.goto('/users');

  // We need the actual response object, not just the UI state
  const [response] = await Promise.all([
    page.waitForResponse((r) => r.url().includes('/api/users') && r.status() === 200),
    page.getByRole('button', { name: 'Refresh' }).click(),
  ]);

  const body = await response.json();
  expect(body.users).toHaveLength(3);
});

test('waits for post-submit redirect to settle', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('pass');
  await page.getByRole('button', { name: 'Submit' }).click();

  // Some apps redirect through /auth-callback → /dashboard.
  // waitForURL gives you the final destination.
  await page.waitForURL(/\/dashboard$/);
});
Application-state race — assert, then interact·typescript
// Application-state race — assert before next interaction
import { test, expect } from '@playwright/test';

test('assert intermediate state before interacting with the new view', async ({ page }) => {
  await page.goto('/items/new');
  await page.getByLabel('Title').fill('New Item');
  await page.getByRole('button', { name: 'Create' }).click();

  // ⚠️ Without this assert, the next click might race against the React commit
  //    that swaps the form for the success view.
  await expect(
    page.getByRole('heading', { name: 'Item Created' })
  ).toBeVisible();

  // NOW it's safe to interact with the success view
  await page.getByRole('button', { name: 'View item' }).click();
});

External links

Exercise

Audit an existing E2E spec in your project for waitForTimeout calls. For each one, ask: what am I actually waiting for? Replace each with either a web-first assertion (expect(locator).toBe...), an explicit waiter (waitForResponse, waitForURL), or — if no condition exists — delete the wait and confirm the test still passes (often auto-wait was already enough).
Hint
If after deleting the sleep the test fails intermittently, you've found an application-state race. Add the missing assertion (expect(thing).toBeVisible()) right before the next action — that's the wait you were really asking for.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.