Introduction
Timeouts in Playwright: a practical guide for developers starts with understanding that timeouts aren't one-size-fits-all. We've all stared at a flaky test that passed yesterday but fails today, often because a timeout fired at the wrong moment. Getting timeouts right means knowing which one to configure, when to increase it, and—most importantly—how to diagnose which timeout actually caused your test to fail.
The difference between a 30-second test timeout and a 60-second test timeout might seem small, but it cascades through your CI pipeline. In a typical e-commerce test suite, we run hundreds of tests daily. Even a 5-second increase per test compounds into wasted hours. This guide walks through the timeout landscape in Playwright so you can make informed decisions and build resilient automation.
Test timeout defaults to 30 seconds
Every Playwright test has a global timeout of 30 seconds by default. If your test doesn't complete within 30 seconds, Playwright stops execution and fails the test with a TimeoutError.
import { test, expect } from '@playwright/test';
test('checkout flow with default timeout', async ({ page }) => {
await page.goto('https://shop.example.com');
await page.click('text=Add to Cart');
await page.click('text=Proceed to Checkout');
// Test must complete within 30 seconds
// If it doesn't, TimeoutError is thrown
});
The 30-second default works for most unit-style tests and quick UI checks. But when you're testing real workflows—a multi-step form submission, file uploads, payment processing—30 seconds can be tight. We've seen this most often in travel booking flows where external payment gateways introduce unpredictable latency.
You can override this at three levels: globally in your config, per file, or per test.
// Global config (playwright.config.ts)
export default defineConfig({
timeout: 60_000, // 60 seconds for all tests
});
// Per file
test.describe.configure({ timeout: 45_000 });
// Per test
test('slow checkout', async ({ page }) => {
test.setTimeout(90_000); // This specific test gets 90 seconds
// test code
});
When a test timeout fires, you see something like:
Test timeout of 30000ms exceeded.
That message doesn't tell you where the test hung. Was it waiting for an element? A navigation? Your application? You need to dig into logs and trace files. This is why we avoid inflating the global timeout and instead increase it only for tests that genuinely need it.
Expect timeout for assertions
Assertions have their own timeout, separate from the test timeout. By default, assertions have a 5-second timeout. This timeout applies to matchers like toBeVisible(), toHaveText(), and toContainText().
import { test, expect } from '@playwright/test';
test('wait for async content to appear', async ({ page }) => {
await page.goto('https://dashboard.example.com');
// This assertion waits up to 5 seconds for the element to appear
// If it doesn't appear within 5 seconds, the test fails
await expect(page.locator('.success-message')).toBeVisible();
});
The 5-second assertion timeout is often generous enough. But in CRM dashboards or trading platforms where data arrives asynchronously, you might need more time.
// Increase for a single assertion
await expect(page.locator('.trade-confirmation')).toBeVisible({
timeout: 15_000, // 15 seconds for this assertion only
});
// Or set globally in your config
export default defineConfig({
expect: {
timeout: 10_000, // 10 seconds for all assertions
},
});
The key difference from test timeouts: when an assertion timeout fires, you get a clear, actionable error message:
Timeout 5000ms exceeded while waiting for expect(locator).toBeVisible()
That specificity makes debugging much easier. You know exactly which assertion failed and for how long it was waiting.
actionTimeout and navigationTimeout
Two other timeouts control lower-level browser interactions: actionTimeout and navigationTimeout.
navigationTimeout (30 seconds by default) applies when Playwright waits for a page to load after a navigation action like goto(), click() on a link, or fill() that triggers a form submission.
// This will wait up to 30 seconds for the page to load
await page.goto('https://api.example.com/slow-endpoint');
// Or set a custom timeout
await page.goto('https://api.example.com/very-slow-endpoint', {
waitUntil: 'networkidle',
timeout: 60_000,
});
actionTimeout (30 seconds by default) is shorter and applies to individual actions like click(), fill(), and press(). If Playwright can't locate an element or perform the action within this window, it fails.
// Default 30-second action timeout
await page.click('text=Login');
// Custom action timeout
await page.click('text=Login', { timeout: 5_000 });
In practice, we rarely override actionTimeout. If an element isn't clickable within 30 seconds, there's usually a deeper issue—a missing selector, a modal overlay, or a stuck application state. Increasing the timeout masks the problem.
However, navigationTimeout sometimes needs adjustment. Slow APIs, third-party integrations, or data-heavy pages can legitimately take longer to load. We've seen multi-tenant SaaS platforms where dashboard initialization can take 45 seconds due to aggregating data from multiple backends.
// Set globally
export default defineConfig({
use: {
navigationTimeout: 60_000,
actionTimeout: 20_000, // Stricter for actions
},
});
Increase only with a reason
This is the rule we live by: never increase a timeout to silence a flaky test. Flakiness is a symptom. Increasing the timeout is treating the symptom, not the disease.
Common flakiness culprits:
- Timing issues: The test assumes an element is ready before it actually is. Add explicit waits instead of increasing timeout.
- Test isolation: One test pollutes state for the next. Fix your setup and teardown.
- Slow selectors: A selector matches many elements or traverses a large DOM. Use more specific locators.
- Environment variability: Tests pass locally but fail in CI due to resource constraints. Profile the environment and adjust infrastructure, not timeouts.
When you do have a legitimate reason to increase a timeout, document it in a comment:
test('generate monthly sales report', async ({ page }) => {
// Report generation queries a year of transaction history
// from three separate databases. Legitimate 120-second timeout.
test.setTimeout(120_000);
await page.goto('/reports');
await page.click('text=Monthly Sales');
await page.click('text=Generate');
await expect(page.locator('.report-ready')).toBeVisible();
});
This comment prevents a future maintainer from randomly reducing the timeout and breaking the test. It also flags for code review: "Why does this test need 2 minutes? Do we need to optimize here?"
Common mistakes
Mistake 1: Confusing test timeout with assertion timeout. You set test.setTimeout(60_000) but assertions still fail after 5 seconds. Remember: they're independent. If assertions are timing out, adjust expect().toBeVisible({ timeout: 15_000 }) or the global expect configuration.
Mistake 2: Setting a single global timeout for all tests. Real-world test suites mix quick smoke tests (5-10 seconds) with slow integration tests (60+ seconds). If you set a global 120-second timeout to accommodate your slowest tests, you waste time waiting on fast tests that fail late. Instead, leave the global timeout at a reasonable default (30-45 seconds) and override only where needed.
Mistake 3: Not enabling trace collection for timeout failures. When a test times out in CI, you have no visibility into what the browser was doing. Traces tell the story. Enable them in your config, especially for timeout failures:
export default defineConfig({
use: {
trace: 'on-first-retry', // Capture trace on retries
},
});
Mistake 4: Ignoring the difference between waitUntil strategies in navigation. goto(url) waits for load by default—the page's load event fires. But complex SPAs might not fire this event reliably. Using waitUntil: 'networkidle' waits for network activity to stop, but can be fragile in apps with background polling. Choose deliberately:
// For SPAs, wait for DOM to be ready
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
// For traditional server-rendered pages
await page.goto('/checkout', { waitUntil: 'load' });
// For APIs that poll, use a custom wait instead
await page.goto('/live-data');
await page.waitForSelector('.data-loaded');
Key takeaways
- Test timeout defaults to 30 seconds globally; override only with justification and a comment.
- Assertion timeout is independent and defaults to 5 seconds; increase it for async operations that genuinely take time.
- navigationTimeout applies to page loads; actionTimeout applies to individual interactions like clicks and fills.
- When a timeout fires, read the error message carefully—it tells you which timeout fired and what was being attempted.
- Never increase a timeout to fix flakiness. Flakiness is a signal to fix the underlying test logic, selectors, or environment.
- Use traces and logs to understand what the browser was actually doing when a timeout occurred.
- Document your timeout overrides with comments explaining why the longer wait is necessary.






Comments
Loading comments…