How to fix Playwright TimeoutError when an action exceeds timeout?
PlaywrightTimeoutError is thrown when Playwright cannot complete an action or find an actionable element within the configured time limit. It typically surfaces as locator.click: Timeout 30000ms exceeded in test output. The most common causes are elements that are slow to appear, hidden behind overlays, or matched by a selector broad enough to cause ambiguity before strict mode rejects it.
Common mistake
test('submits form', async ({ page }) => {
await page.goto('https://example.com/login');
// No wait — element may not be in the DOM or enabled yet
await page.locator('button').click();
});
Using a generic locator and calling an action immediately races against UI rendering, especially on slow CI machines or after navigations that require server round-trips.
The fix
import { test, expect } from '@playwright/test';
test('submits login form', async ({ page }) => {
await page.goto('https://example.com/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret');
const submit = page.getByRole('button', { name: 'Sign in' });
await expect(submit).toBeEnabled({ timeout: 10000 });
await submit.click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15000 });
});
For project-wide defaults, configure them once in playwright.config.ts so individual tests inherit consistent limits:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
actionTimeout: 10000,
navigationTimeout: 15000,
},
});
Why it works
Playwright's expect assertions use built-in auto-retry with configurable timeouts, polling until the condition is satisfied rather than failing on the first check. Using toBeEnabled() before click() confirms the button is fully interactive, eliminating the race between script execution and UI state transitions. Setting explicit timeouts in config prevents 30-second default waits from masking slow page transitions in CI while making the intended limits visible in code review.
Tips
- Use page.getByRole() with a name option instead of generic page.locator('button') — it resolves fewer candidates and provides a more descriptive failure message.
- If the element is present but blocked by a loading spinner, wait for the spinner to disappear first: await expect(spinner).toBeHidden().
- Timeouts that only trigger in CI often indicate slower render cycles — increasing actionTimeout in CI-specific config is a legitimate and targeted fix.
- If the button is visible but clicks still time out, it may be covered by an invisible overlay — see how to fix click timeout not visible for that scenario.