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:
actionTimeoutin playwright.config.ts (default 30 s)
For assertions (expect(locator).toBeVisible()):
- Retries until the condition holds
- Timeout configurable per assertion
- Global default:
assertionTimeoutin 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)
});