How to fix Playwright tests that overuse `waitForTimeout` and still flake?

Playwright

page.waitForTimeout() (a hard sleep) is the most common anti-pattern in Playwright test suites. It introduces fixed delays that are simultaneously too short (causing intermittent failures on slow CI machines) and too long (wasting time on fast machines). Tests built on waitForTimeout flake not because of logic errors, but because the delay was calibrated to one environment and doesn't hold in another. Playwright's assertion and event-based waits are designed specifically to replace every legitimate use case for hard sleeps.

Common mistake

test('saves settings', async ({ page }) => {
  await page.goto('https://app.example.com/settings');

  await page.getByRole('button', { name: 'Save' }).click();

  // Wait 2 seconds hoping the save operation completes
  await page.waitForTimeout(2000);

  // Flakes: passes when save takes < 2s, fails when it takes longer
  await expect(page.getByText('Settings saved')).toBeVisible();
});

Also common after navigation:

await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForTimeout(1500); // Wait for page to load
// Flakes on slow CI, wastes time on fast machines

The fix

Replace every waitForTimeout with the appropriate Playwright wait:

After clicking a button that shows a success state:

import { test, expect } from '@playwright/test';

test('saves settings', async ({ page }) => {
  await page.goto('https://app.example.com/settings');

  await page.getByRole('button', { name: 'Save' }).click();

  // Wait for the actual success indicator — no fixed delay needed
  await expect(page.getByRole('alert')).toContainText('Settings saved', {
    timeout: 10000,
  });
});

After clicking a link that navigates:

test('opens reports page', async ({ page }) => {
  await page.goto('https://app.example.com/dashboard');

  await page.getByRole('link', { name: 'Reports' }).click();

  // waitForURL retries until the URL matches — replaces post-click sleep
  await page.waitForURL('**/reports');
  await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
});

Waiting for a spinner to disappear:

test('loads dashboard data', async ({ page }) => {
  await page.goto('https://app.example.com/dashboard');

  // Wait for loading state to clear instead of sleeping
  await expect(page.getByTestId('data-loading')).toBeHidden({ timeout: 15000 });
  await expect(page.getByTestId('revenue-chart')).toBeVisible();
});

The one legitimate use: debugging only.

// Acceptable temporarily during test development — never in committed code
await page.waitForTimeout(5000); // TODO: replace with proper wait

Why it works

Playwright's expect assertions poll the condition every ~100ms and resolve as soon as it passes — a 200ms server response triggers the assertion in ~200ms, not after a 2-second sleep. This makes tests both faster and more reliable: they adapt to actual timing rather than assuming fixed timing. The same adaptive behavior handles CI slowness automatically — a 10-second timeout on toBeVisible() accommodates slow machines without adding 10 seconds to fast runs.

Tips

  • Search your test suite for waitForTimeout and treat each occurrence as a TODO — there is always a better wait that makes the test both faster and more reliable.
  • The only scenario where waitForTimeout is technically justified is when you need to wait for a duration-based side effect (e.g., "this token expires after 60 seconds") — but that scenario usually indicates a test design problem worth solving at the architecture level.
  • Playwright's --timeout flag sets the maximum per-test time — if tests run under waitForTimeout-based timing, the total test time is artificially inflated and CI costs rise proportionally.
  • Replace groups of waitForTimeout calls by identifying the correct readiness signal: for API calls use waitForResponse, for navigation use waitForURL, for element state changes use expect().toBeVisible()/toBeHidden().