Playwright auto-waits for most things. Click a button? Playwright waits for it to be visible and enabled. Fill an input? Playwright waits for it to be editable. But auto-waiting has limits. API calls that take 10 seconds, pages that load data after rendering, spinners that block the UI -- you need explicit waits for these.
Before we talk about explicit waits, know what Playwright already does for you. Every action method (click, fill, check) auto-waits for the element to be actionable. That means: attached to DOM, visible, stable (not animating), enabled, and receiving events. The default timeout is 30 seconds.
import { test, expect } from '@playwright/test';
test('wait for page to fully load', async ({ page }) => {
await page.goto('/banking/dashboard');
// Wait for DOMContentLoaded event
await page.waitForLoadState('domcontentloaded');
// Wait for all network requests to finish (fonts, images, etc.)
await page.waitForLoadState('networkidle');
// Now the page is fully loaded
await expect(page.getByTestId('balance')).toBeVisible();
});import { test, expect } from '@playwright/test';
test('wait for loading spinner to disappear', async ({ page }) => {
await page.goto('/banking/dashboard');
// Wait for the spinner to appear and then disappear
await page.waitForSelector('.loading-spinner', { state: 'hidden' });
// Now the data is loaded
await expect(page.getByRole('table')).toBeVisible();
});
test('wait for element to be attached to DOM', async ({ page }) => {
await page.goto('/topics/dynamic-content');
// Wait for a lazy-loaded section
await page.waitForSelector('#lazy-section', { state: 'attached' });
// Now interact with it
await page.locator('#lazy-section').getByRole('button').click();
});import { test, expect } from '@playwright/test';
test('wait for API response after action', async ({ page }) => {
await page.goto('/banking/dashboard');
// Wait for the API response AND click the button
const [response] = await Promise.all([
page.waitForResponse(resp =>
resp.url().includes('/api/transactions') && resp.status() === 200
),
page.getByRole('button', { name: 'Load Transactions' }).click(),
]);
// Optionally inspect the response
const data = await response.json();
expect(data.transactions.length).toBeGreaterThan(0);
// Verify the data appeared on the page
await expect(page.getByRole('table')).toBeVisible();
});
test('wait for specific URL pattern', async ({ page }) => {
await page.goto('/shopping');
const [response] = await Promise.all([
page.waitForResponse('**/api/products*'),
page.getByRole('button', { name: 'Apply Filter' }).click(),
]);
expect(response.status()).toBe(200);
});import { test, expect } from '@playwright/test';
test('wait for redirect after login', async ({ page }) => {
await page.goto('/banking/login');
await page.getByLabel('Email').fill('john@test.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log in' }).click();
// Wait for the URL to change to dashboard
await page.waitForURL('**/banking/dashboard');
// Or use regex
await page.waitForURL(/\/dashboard/);
await expect(page.getByTestId('welcome-message')).toBeVisible();
});import { test, expect } from '@playwright/test';
test('wait for element text to change', async ({ page }) => {
await page.goto('/banking/transfer');
await page.getByRole('button', { name: 'Transfer' }).click();
// Wait for status to change from "Processing" to "Complete"
await expect(page.getByTestId('status')).toHaveText('Complete', {
timeout: 15000, // Custom timeout for slow operations
});
});
test('wait for element count to stabilize', async ({ page }) => {
await page.goto('/shopping');
// Wait until exactly 12 products are loaded
await expect(page.locator('.product-card')).toHaveCount(12, {
timeout: 10000,
});
});
test('poll until condition is met', async ({ page }) => {
await page.goto('/banking/transfer');
await page.getByRole('button', { name: 'Transfer' }).click();
// Use expect.poll for custom conditions
await expect.poll(async () => {
const text = await page.getByTestId('status').textContent();
return text;
}, {
timeout: 20000,
intervals: [1000, 2000, 5000], // Check at these intervals
}).toBe('Complete');
});| Scope | How to Set | Default |
|---|---|---|
| Global (all tests) | playwright.config.ts: timeout: 60000 | 30 seconds |
| Specific test | test.setTimeout(60000) | Inherits global |
| Action timeout | playwright.config.ts: actionTimeout: 10000 | 30 seconds (same as global) |
| Single assertion | expect(x).toBeVisible({ timeout: 5000 }) | Inherits config |
| Navigation | playwright.config.ts: navigationTimeout: 30000 | 30 seconds |
| waitForSelector | waitForSelector(sel, { timeout: 5000 }) | 30 seconds |
Never use page.waitForTimeout() (the Playwright equivalent of sleep). It is a hard wait -- it always waits the full duration even if the condition is already met. Use it only for debugging, never in actual tests. Use assertions with retries or waitFor methods instead.
When a test is flaky because of timing, do not increase the timeout. Find the right wait condition. Is there a spinner? Wait for it to disappear. Is there an API call? Wait for the response. Is the text changing? Assert on the final text. The right wait is always a condition, never a duration.
Q: How do you handle waits and avoid flaky tests in Playwright?
A: Playwright auto-waits for most actions, which handles 90% of timing issues. For the remaining 10%, I use explicit waits: waitForResponse() for API calls, waitForURL() for navigation, waitForSelector({ state: 'hidden' }) for spinners, and expect().toBeVisible() for elements. For complex conditions, I use expect.poll() which retries at configurable intervals. I never use waitForTimeout() (sleep). When a test is flaky, I find the right condition to wait for instead of adding arbitrary delays.
Key Point: Playwright auto-waits for actions. For explicit waits: waitForResponse for APIs, waitForURL for navigation, waitForSelector for spinners, expect.poll for custom conditions. Never use sleep.