page.waitForTimeout(2000) before an assertion is almost always wrong: Playwright assertions already poll until they pass, so the sleep is redundant on fast runs and still too short on slow ones. The correct pattern is to let the assertion do the waiting, or use waitForResponse inside Promise.all to capture a specific API call before asserting its result. This article covers Playwright's auto-waiting conditions, when they're not enough, and the five explicit waiting APIs including which to use and which to treat as a code smell.

How Playwright auto-waiting works

When you write await page.getByRole('button', { name: 'Submit' }).click(), Playwright doesn't click immediately. It waits for the button to be:

  • Attached to the DOM
  • Visible (not hidden, not display: none)
  • Stable (not animating)
  • Enabled (not disabled)
  • Receiving pointer events (not covered by another element)

This happens automatically, up to the actionTimeout (default: 30 seconds). You don't write any waiting code; Playwright handles it.

This is why Playwright is faster to write than Selenium. In Selenium, you'd write WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ROLE, 'button'))). In Playwright: just click.

When auto-waiting isn't enough

Auto-waiting works for element interactions. It doesn't handle everything:

Page navigation: After a form submit or link click, the URL changes. Auto-waiting won't wait for the new page to fully load before your next action. Data loading: An element exists and is visible, but it shows a spinner while data loads. Playwright might click it before the data arrives. Multiple network requests: A page load triggers three API calls. Playwright sees the DOM is ready, but the third API call isn't complete yet. Animation completion: An element is technically visible but mid-animation. Auto-waiting handles simple CSS transitions but not all animation states.

expect as a waiting tool

The most underused waiting pattern in Playwright: assertions wait.

// This waits up to the timeout for the URL to match
await expect(page).toHaveURL('/dashboard');

// This waits for the text to appear
await expect(page.getByRole('heading')).toHaveText('Order confirmed');

// This waits for the element count to reach 5
await expect(page.getByRole('listitem')).toHaveCount(5);

Every expect assertion in Playwright polls until it passes or times out. This makes assertions the cleanest way to wait for application state.

Anti-pattern:

await page.waitForTimeout(2000); // never do this
await expect(page.getByRole('heading')).toHaveText('Order confirmed');

Correct:

await expect(page.getByRole('heading')).toHaveText('Order confirmed');

The expect does the waiting. The waitForTimeout is a smell that means you don't know what to wait for.

Explicit waiting tools

When you do need explicit waits, Playwright provides targeted options:

waitForURL

await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('/dashboard');
// Now safe to assert on dashboard content

waitForResponse: wait for a specific API call

const [response] = await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/api/orders') && resp.status() === 200),
  page.getByRole('button', { name: 'Place order' }).click(),
]);
const orderData = await response.json();
expect(orderData.status).toBe('created');

Start the wait before the action that triggers the request. Promise.all ensures you don't miss a fast response.

waitForRequest: verify a request was made

const [request] = await Promise.all([
  page.waitForRequest(req => req.url().includes('/api/track') && req.method() === 'POST'),
  page.getByRole('button', { name: 'Purchase' }).click(),
]);
// Verify analytics event fired
expect(request.postDataJSON()).toMatchObject({ event: 'purchase' });

waitForSelector: wait for an element state

// Wait for loading spinner to disappear
await page.waitForSelector('.spinner', { state: 'detached' });

// Wait for element to become visible
await page.waitForSelector('[data-testid="results-table"]', { state: 'visible' });

state options: 'attached', 'detached', 'visible', 'hidden'.

Prefer expect(locator).toBeVisible() over waitForSelector; the assertion approach is more readable.

waitForLoadState

await page.goto('/heavy-page');
await page.waitForLoadState('networkidle'); // Wait until no network activity for 500ms

loadState options:
  • 'load': window.load event fired (default for goto)
  • 'domcontentloaded': DOM parsed, before images/scripts
  • 'networkidle': no network requests for 500ms
'networkidle' is slow and brittle, so avoid it unless the page genuinely has no other way to signal "ready". Prefer waiting for a specific element instead.

Timeout configuration

Timeouts are configurable at three levels:

// playwright.config.ts — applies to all tests
export default defineConfig({
  timeout: 30000,          // Test timeout (entire test)
  expect: {
    timeout: 5000,         // Assertion timeout
  },
  use: {
    actionTimeout: 15000,  // Individual action timeout (click, fill, etc.)
    navigationTimeout: 30000,
  },
});

Override per-test:

test('slow data load', async ({ page }) => {
  test.setTimeout(60000); // This test gets 60 seconds
  // ...
});

Override per-assertion:

await expect(page.getByText('Report generated')).toBeVisible({ timeout: 30000 });

The waitForTimeout rule

page.waitForTimeout(ms) is a sleep. It's sometimes necessary as a last resort (for example, waiting for a third-party script you can't observe). But treat every occurrence as a TODO: what should you really be waiting for here?

If you find yourself writing await page.waitForTimeout(1000) in more than one or two tests, your test suite has a structural waiting problem worth fixing.

→ See also: Debugging Flaky Tests: A Practical Guide | Flaky Tests: Why They Happen and How to Eliminate Them | Playwright Assertions: The Complete Guide