Timeout exceeded. If you have used Playwright for more than a day, you have seen this error. It appears in different forms — navigation timeout of 30000ms exceeded, waiting for selector, expect.toBeVisible timed out — but the underlying problem is always the same: Playwright waited for something and it did not happen fast enough.
The mistake most developers make is reaching for a blanket timeout increase or, worse, sprinkling page.waitForTimeout() calls throughout their tests. Both approaches mask the real problem and make tests slower and more fragile. The correct approach is to understand which timeout fired, why it fired, and configure the right timeout at the right level.
Understanding Playwright's Timeout System
Playwright has four independent timeout types. Each one controls a different aspect of test execution, has its own default value, and is configured separately. Mixing them up is the root cause of most timeout confusion.
| Timeout Type | Default | What It Controls | Config Key |
|---|---|---|---|
| Test timeout | 30,000 ms | Total time for the entire test (including all actions, navigations, and assertions) | timeout |
| Navigation timeout | 30,000 ms | page.goto(), page.waitForURL(), page.reload(), page.goBack() |
navigationTimeout |
| Action timeout | 0 (no limit) | click(), fill(), check(), selectOption(), and all other locator actions |
actionTimeout |
| Expect timeout | 5,000 ms | expect(locator).toBeVisible(), toHaveText(), toHaveURL(), and all web-first assertions |
expect.timeout |
Critical distinction: The action timeout defaults to 0 (no limit). This means page.click('.button') will wait forever for the button to appear — or until the test timeout fires. If you see a test timeout error during a click operation, the fix is usually to set actionTimeout in your config rather than increasing the test timeout.
Test Timeout
The test timeout is the outer boundary — the maximum time an entire test function is allowed to run. It includes all navigations, actions, assertions, and any waiting. When this timeout fires, the test is killed immediately regardless of what it was doing.
Default: 30 seconds
For most unit-style Playwright tests, 30 seconds is more than enough. But end-to-end flows that involve multiple page navigations, file uploads, or slow backend APIs can legitimately need more time.
Setting test timeout globally
import { defineConfig } from '@playwright/test'; export default defineConfig({ // Global test timeout: 60 seconds timeout: 60_000, });
Setting test timeout per test
import { test, expect } from '@playwright/test'; test('complete checkout flow', async ({ page }) => { // Override timeout for this test only test.setTimeout(120_000); // 2 minutes await page.goto('/products'); await page.getByRole('button', { name: 'Add to Cart' }).click(); await page.goto('/checkout'); // ... rest of checkout flow });
Using test.slow() to triple the timeout
test('upload large file', async ({ page }) => { // Triples the default timeout (30s → 90s) test.slow(); await page.goto('/upload'); await page.setInputFiles('input[type="file"]', './large-dataset.csv'); await expect(page.getByText('Upload complete')).toBeVisible(); });
Best practice: Use test.slow() for tests you know are inherently slow (file uploads, video processing, report generation). It communicates intent to your team — this test is slow by design, not by accident. Reserve test.setTimeout() for precise values.
Navigation Timeout
The navigation timeout controls how long Playwright waits for page navigation to complete. It applies to page.goto(), page.waitForURL(), page.reload(), page.goBack(), and page.goForward().
Default: 30 seconds
The most common error you will see is:
page.goto: Navigation timeout of 30000ms exceeded. ==================== logs ==================== navigating to "https://example.com/dashboard", waiting until "load"
The waitUntil option
The key to fixing navigation timeouts is understanding the waitUntil parameter. It controls what Playwright considers a "completed" navigation:
'load'(default) — waits for theloadevent. This means all resources (images, stylesheets, iframes) must finish loading. The slowest option but the most complete.'domcontentloaded'— waits for theDOMContentLoadedevent. The HTML is parsed and DOM is ready, but images and stylesheets may still be loading. Fast and sufficient for most tests.'networkidle'— waits until there are no more than 0 network connections for 500ms. Useful for SPAs that load data after the initial page load. Can be unreliable if the page has persistent connections (WebSockets, long-polling).'commit'— waits only for the network response to be received and the document to start loading. The fastest option. Useful when you just need the page to start rendering.
// Default: waits for 'load' event — all resources loaded await page.goto('https://example.com'); // Faster: only wait for DOM to be ready await page.goto('https://example.com', { waitUntil: 'domcontentloaded', }); // For SPAs: wait until network is idle await page.goto('https://example.com/dashboard', { waitUntil: 'networkidle', }); // Fastest: just wait for response headers await page.goto('https://example.com', { waitUntil: 'commit', }); // Override navigation timeout for a single call await page.goto('https://slow-api.example.com', { timeout: 60_000, // 60 seconds });
Setting navigation timeout globally
export default defineConfig({ use: { navigationTimeout: 60_000, // 60s for all navigations }, });
Tip: If your app is an SPA (React, Vue, Angular), most "page navigations" are actually client-side route changes that do not trigger a real browser navigation. In that case, page.goto() only fires on the initial load. For subsequent route changes, use page.waitForURL() or expect(page).toHaveURL() instead.
Action Timeout
The action timeout controls how long Playwright waits for locator actions to succeed — click(), fill(), check(), selectOption(), press(), and others. Before performing an action, Playwright runs a series of actionability checks: it waits for the element to be visible, enabled, stable (not animating), and able to receive events.
Default: 0 (no limit)
This is the most surprising default. Actions wait indefinitely — they will only fail when the test timeout expires. This means if page.click('.submit-button') cannot find the button, you will not get a clear "action timed out" error. Instead, you get a generic "test timeout" error that does not tell you which action was stuck.
Setting a sensible action timeout
export default defineConfig({ use: { actionTimeout: 10_000, // 10s — if an element doesn't appear in 10s, fail fast }, });
Per-action timeout override
// Override for a single action await page.getByRole('button', { name: 'Submit' }).click({ timeout: 15_000, // 15 seconds for this click only }); // Useful for elements that appear after slow operations await page.getByText('Processing complete').click({ timeout: 30_000, });
Recommendation: Always set actionTimeout in your config. Leaving it at 0 means you never get specific action timeout errors — only vague test timeouts. A value of 10,000ms (10 seconds) is a good starting point. Any element that takes longer than 10 seconds to appear is usually a symptom of a real performance issue or a wrong selector.
Expect/Assertion Timeout
The expect timeout controls how long Playwright's web-first assertions retry before failing. Assertions like expect(locator).toBeVisible(), expect(locator).toHaveText(), and expect(page).toHaveURL() are auto-retrying — they poll the page repeatedly until the condition is met or the timeout expires.
Default: 5 seconds
Five seconds is usually enough for UI elements that are already present or appearing via a fast interaction. But it is too short for assertions that depend on API responses, animations, or server-side processing.
expect(locator).toBeVisible Timed out 5000ms waiting for expect(locator).toBeVisible() Locator: getByText('Order confirmed') Expected: visible Received: hidden
Setting expect timeout globally
export default defineConfig({ expect: { timeout: 10_000, // 10s for all assertions }, });
Per-assertion timeout override
// Wait up to 30 seconds for this specific assertion await expect(page.getByText('Report generated')).toBeVisible({ timeout: 30_000, }); // Wait for URL to change after form submission await expect(page).toHaveURL(/\/success/, { timeout: 15_000, }); // Wait for text content to update await expect(page.getByTestId('status')).toHaveText('Complete', { timeout: 20_000, });
Using expect.configure() for scoped timeout changes
import { test, expect } from '@playwright/test'; test('dashboard loads with data', async ({ page }) => { // Create a custom expect with a longer timeout const slowExpect = expect.configure({ timeout: 20_000 }); await page.goto('/dashboard'); // These assertions use the 20s timeout await slowExpect(page.getByTestId('chart')).toBeVisible(); await slowExpect(page.getByTestId('data-table')).toHaveCount(10); // Regular expect still uses the default 5s timeout await expect(page.getByRole('heading')).toHaveText('Dashboard'); });
Global vs Per-Test vs Per-Action Timeout Configuration
Here is a complete playwright.config.ts showing all four timeout types configured together:
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ // 1. Test timeout — max time per test timeout: 60_000, // 60 seconds // 2. Expect timeout — max time per assertion expect: { timeout: 10_000, // 10 seconds }, use: { // 3. Action timeout — max time per click/fill/etc actionTimeout: 10_000, // 10 seconds // 4. Navigation timeout — max time per goto/reload navigationTimeout: 30_000, // 30 seconds baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'api-tests', timeout: 120_000, // API tests get 2 minutes use: { actionTimeout: 0, // No action timeout for API tests }, }, ], });
The precedence order from most specific to least specific is:
- Per-action/per-assertion —
.click({ timeout: 5000 })or.toBeVisible({ timeout: 10000 }) - Per-test —
test.setTimeout(120_000) - Per-project —
projects: [{ timeout: 60000, use: { actionTimeout: 10000 } }] - Global config — top-level
timeout,expect.timeout,use.actionTimeout,use.navigationTimeout - Playwright defaults — 30s test, 0 action, 30s navigation, 5s expect
Debugging Timeout Errors
When a timeout fires, the error message tells you what timed out but not always why. These tools help you find the root cause.
1. Trace Viewer
The Trace Viewer is the most powerful debugging tool for timeout issues. It records a timeline of every action, network request, console log, and DOM snapshot — letting you see exactly what the page looked like at the moment the timeout occurred.
export default defineConfig({ use: { // Record trace on first retry — keeps CI fast while capturing debug data trace: 'on-first-retry', }, retries: 1, });
# After a failed test, open the trace npx playwright show-trace test-results/my-test-chromium/trace.zip # Or view it in the HTML report npx playwright show-report
In the trace, look for: which action was executing when the timeout fired, what the page looked like (DOM snapshot), which network requests were still pending, and any console errors.
2. Debug mode with --debug flag
Running with --debug opens the Playwright Inspector alongside the browser. You can step through actions one at a time, inspect locators, and see exactly what the test sees.
# Run a specific test in debug mode npx playwright test my-test.spec.ts --debug # Debug a specific test by title npx playwright test -g "checkout flow" --debug
3. page.pause() for interactive debugging
Insert page.pause() at the point where your test times out. This pauses execution and opens the Playwright Inspector, letting you interact with the page and test locators in real time.
test('debug timeout issue', async ({ page }) => { await page.goto('/checkout'); await page.getByLabel('Card number').fill('4242424242424242'); // Pause here to inspect the page before the failing action await page.pause(); // This action was timing out — now you can see why await page.getByRole('button', { name: 'Pay Now' }).click(); });
4. Screenshots on failure
Playwright captures a screenshot automatically when a test fails. Configure this in your config to always have a visual record of the failure state:
export default defineConfig({ use: { screenshot: 'only-on-failure', video: 'retain-on-failure', }, });
Common Timeout Scenarios and Fixes
Here are the timeout situations you will encounter most often, along with the correct fix for each.
Scenario 1: Slow API response delays page content
The page loads but the content you need depends on an API call that takes 10+ seconds.
// BAD: This assertion times out because the API is slow await page.goto('/dashboard'); await expect(page.getByTestId('user-table')).toBeVisible(); // ❌ 5s default // GOOD: Wait for the API response, then assert await page.goto('/dashboard'); await page.waitForResponse( resp => resp.url().includes('/api/users') && resp.status() === 200 ); await expect(page.getByTestId('user-table')).toBeVisible();
Scenario 2: Lazy-loaded content below the fold
Elements that load only when scrolled into view will not appear until you scroll to them.
// BAD: Element exists in DOM but is lazy-loaded — never renders await expect(page.getByTestId('footer-newsletter')).toBeVisible(); // ❌ timeout // GOOD: Scroll to the element first await page.getByTestId('footer-newsletter').scrollIntoViewIfNeeded(); await expect(page.getByTestId('footer-newsletter')).toBeVisible();
Scenario 3: SPA client-side navigation
In SPAs, clicking a link does not trigger a real navigation event. page.waitForNavigation() will time out.
// BAD: SPA route change doesn't trigger navigation event await Promise.all([ page.waitForNavigation(), // ❌ times out page.click('a[href="/settings"]'), ]); // GOOD: Wait for URL change instead await page.click('a[href="/settings"]'); await expect(page).toHaveURL(/\/settings/);
Scenario 4: iframe content
Elements inside iframes require accessing the frame first. Trying to find them on the main page will time out.
// BAD: Element is inside an iframe — not visible on main page await page.getByRole('button', { name: 'Submit Payment' }).click(); // ❌ timeout // GOOD: Access the iframe's content frame const paymentFrame = page.frameLocator('iframe[name="payment"]'); await paymentFrame.getByRole('button', { name: 'Submit Payment' }).click();
Scenario 5: Popup or new tab
Actions that open a new window or popup require waiting for the popup event. Interacting with the original page will not find elements in the new tab.
// BAD: Content is in a new tab — not on the current page await page.getByText('Open Preview').click(); await expect(page.getByText('Preview Mode')).toBeVisible(); // ❌ timeout // GOOD: Capture the popup and interact with it const popupPromise = page.waitForEvent('popup'); await page.getByText('Open Preview').click(); const popup = await popupPromise; await popup.waitForLoadState(); await expect(popup.getByText('Preview Mode')).toBeVisible();
Scenario 6: Element hidden behind overlay or modal
Playwright's actionability checks ensure an element is not obscured before clicking. If a modal, cookie banner, or loading overlay covers the element, the click will wait until the overlay disappears — and time out if it does not.
// GOOD: Dismiss the overlay first, then click await page.getByRole('button', { name: 'Accept Cookies' }).click(); await expect(page.locator('.cookie-banner')).toBeHidden(); // Now the target element is no longer obscured await page.getByRole('button', { name: 'Add to Cart' }).click();
Wait Strategies: The Right Way to Wait
Playwright provides several event-driven waiting methods that are far superior to hard timeouts. Each one waits for a specific condition, making your tests both faster and more reliable.
waitForSelector
Waits for an element matching the selector to appear (or disappear) from the DOM.
// Wait for element to appear in the DOM await page.waitForSelector('.loading-spinner', { state: 'attached' }); // Wait for element to be visible (default state) await page.waitForSelector('.results-table'); // Wait for element to disappear (loading state cleared) await page.waitForSelector('.loading-spinner', { state: 'hidden' }); // Wait for element to be detached from DOM entirely await page.waitForSelector('.modal-overlay', { state: 'detached' });
waitForLoadState
Waits for the page to reach a specific load state. Useful after actions that trigger resource loading.
// Wait for all network requests to complete await page.waitForLoadState('networkidle'); // Wait for DOM to be fully parsed await page.waitForLoadState('domcontentloaded'); // Wait for all resources (images, stylesheets) to load await page.waitForLoadState('load');
waitForResponse
Waits for a specific network response. This is the most precise wait when your UI depends on an API call.
// Wait for a specific API response before asserting await page.getByRole('button', { name: 'Search' }).click(); const response = await page.waitForResponse( resp => resp.url().includes('/api/search') && resp.status() === 200 ); const data = await response.json(); console.log(`Search returned ${data.results.length} results`); await expect(page.getByTestId('results')).toBeVisible();
waitForURL
Waits for the page URL to match a pattern. Essential for SPA navigation and form submissions that redirect.
// Wait for redirect after login await page.getByRole('button', { name: 'Sign In' }).click(); await page.waitForURL('**/dashboard'); // Wait for URL to match a regex await page.waitForURL(/\/orders\/\d+/); // With custom timeout await page.waitForURL('**/success', { timeout: 15_000 });
waitForFunction
Evaluates a JavaScript function in the browser context and waits until it returns a truthy value. Use this for custom conditions that no built-in wait covers.
// Wait for a JavaScript variable to be set await page.waitForFunction(() => { return (window as any).appReady === true; }); // Wait for element count to reach a specific number await page.waitForFunction(() => { return document.querySelectorAll('.list-item').length >= 10; });
Anti-Patterns: What NOT to Do
These patterns are the most common causes of flaky and slow Playwright tests. Avoid all of them.
Anti-Pattern 1: page.waitForTimeout() (hard sleep)
// ❌ BAD: Hard sleep — always waits 3 seconds even if element is ready instantly await page.goto('/dashboard'); await page.waitForTimeout(3000); await expect(page.getByTestId('chart')).toBeVisible(); // ✅ GOOD: Event-driven wait — resolves as soon as the chart appears await page.goto('/dashboard'); await expect(page.getByTestId('chart')).toBeVisible();
page.waitForTimeout() exists only for debugging purposes. It has no place in production test code. It slows down your test suite by the exact amount of the delay on every single run, and it is still flaky — if the page needs 3.1 seconds on a slow CI machine, your 3-second wait fails.
Anti-Pattern 2: Blanket timeout increases
// ❌ BAD: Increasing all timeouts to hide a specific problem export default defineConfig({ timeout: 300_000, // 5 minutes per test! expect: { timeout: 60_000 }, // 60s per assertion! use: { actionTimeout: 60_000, navigationTimeout: 120_000 }, }); // ✅ GOOD: Increase only the specific timeout that needs it export default defineConfig({ timeout: 60_000, // Reasonable test timeout expect: { timeout: 10_000 }, use: { actionTimeout: 10_000, navigationTimeout: 30_000 }, });
Blanket increases mask real issues. If one test needs 2 minutes, use test.setTimeout(120_000) in that specific test rather than giving every test a 5-minute budget.
Anti-Pattern 3: Ignoring the root cause
// ❌ BAD: Retrying without understanding why it fails export default defineConfig({ retries: 5, // Just keep retrying until it passes }); // ✅ GOOD: Use retries for genuine flakiness, not as a fix export default defineConfig({ retries: process.env.CI ? 1 : 0, // 1 retry in CI, 0 locally use: { trace: 'on-first-retry' }, // Capture trace to debug the flake });
Anti-Pattern 4: Using waitForSelector when an assertion works
// ❌ UNNECESSARY: Manual wait + assertion await page.waitForSelector('.success-message'); const text = await page.textContent('.success-message'); expect(text).toBe('Order placed successfully'); // ✅ BETTER: Web-first assertion handles the wait automatically await expect(page.getByText('Order placed successfully')).toBeVisible();
Playwright's web-first assertions (expect(locator).toBeVisible(), toHaveText(), etc.) already auto-retry. Adding a manual waitForSelector before them is redundant and makes the code harder to read.
Frequently Asked Questions
How do I fix "Navigation timeout of 30000ms exceeded" in Playwright?
This means page.goto() did not complete within 30 seconds. Fix it by: increasing the timeout with page.goto(url, { timeout: 60000 }), using a less strict waitUntil option like 'domcontentloaded' or 'commit', or setting navigationTimeout globally in your config. Also investigate whether the page genuinely loads slowly due to heavy assets or slow APIs.
What is the default timeout in Playwright?
Playwright has four defaults: test timeout is 30 seconds, action timeout is 0 (no limit), navigation timeout is 30 seconds, and expect timeout is 5 seconds. All four can be configured independently in playwright.config.ts using timeout, use.actionTimeout, use.navigationTimeout, and expect.timeout.
How do I increase the timeout for a single Playwright test?
Use test.setTimeout(120_000) inside the test body for a precise value, or test.slow() to triple the default timeout. You can also set timeout per-project in playwright.config.ts to adjust all tests in that project.
Should I use page.waitForTimeout() in Playwright tests?
No. page.waitForTimeout() is a hard sleep that makes tests slow and flaky. Use event-driven waits instead: page.waitForSelector(), page.waitForLoadState(), page.waitForResponse(), or auto-retrying assertions like expect(locator).toBeVisible(). These resolve as soon as the condition is met rather than waiting a fixed duration.
How do I debug Playwright timeout errors?
Use the Trace Viewer (trace: 'on-first-retry' in config, then npx playwright show-trace) to see a timeline of actions and DOM snapshots at the moment of failure. Run npx playwright test --debug for step-by-step debugging with the Playwright Inspector. Insert page.pause() in your test code to pause at a specific point and inspect the page interactively.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.