Diagnosing Playwright TimeoutError: Auto‑Waiting Failures and Locator Instability
Guide to diagnosing Playwright TimeoutError: identify failing actionability checks, trace root causes with Inspector/Trace Viewer, and apply fixes—resilient locators, overlay waits, and network sync—without global timeouts.
02 Aug 2025, 08:35 UTC

Recognizable Condition
Your Playwright test fails with TimeoutError: locator.click: Timeout 30000ms exceeded (or similar action timeout). The error message typically lists the actionability checks that failed: visible, stable, enabled, receiving events. The test hangs at the action line—await page.getByRole('button', { name: 'Submit' }).click()—without throwing a selector‑not‑found error, meaning the locator matched an element but that element never satisfied Playwright’s actionability contract.
Cause and Diagnostic Quick‑Reference
| Symptom in Error Output | Likely Root Cause | First Diagnostic Step |
|---|---|---|
| "element is not visible" | CSS display:none, opacity:0, zero size, or covered by overlay | Inspect computed styles in Trace Viewer snapshot |
| "element is not stable" | Animation, transition, or re‑render moving the element between frames | Enable PWDEBUG=1 and watch for position jitter |
| "element is not enabled" | disabled attribute, aria-disabled=true, or readonly input | Check DOM attributes at timeout moment via trace |
| "element is not receiving events" | Overlay (spinner, modal backdrop, cookie banner) intercepting pointer events | Use page.locator('*').evaluateAll(...) to find element at click coordinates |
| "element is detached" | SPA re‑render replaced the DOM node after locator resolution but before action | Switch to user‑facing locators (getByRole, getByText) that re‑query |
Ordered Diagnostic Checks
1. Capture a Trace with Snapshots
Run the failing test with tracing enabled to get a full timeline:
# Terminal (project root)
npx playwright test --trace=on-first-retry failing-test.spec.ts
Open the generated trace.zip in trace.playwright.dev. Navigate to the action that timed out. The Call tab shows each actionability check with pass/fail; the Snapshot tab shows the exact DOM and computed styles at that millisecond.
2. Step Through with Playwright Inspector
# Terminal
PWDEBUG=1 npx playwright test failing-test.spec.ts
The Inspector pauses before the failing action. Use the "Explore" picker to hover the target element—Playwright highlights which actionability check is currently failing in the sidebar. This is faster than trace for single‑run debugging.
3. Verify Locator Resilience
If the error shows "element is detached", your locator likely captured a stale node. Replace brittle CSS/XPath with user‑facing locators that re‑query on each action:
// Brittle – captures a specific node once
await page.locator('.submit-btn').click();
// Resilient – re‑queries by accessible role/name before every action
await page.getByRole('button', { name: 'Submit' }).click();
Run the test again. If the timeout disappears, the cause was DOM churn between resolution and action.
4. Check for Overlapping Elements
When "not receiving events" appears, something sits above your target. In the Trace Viewer snapshot, enable the "Layout" overlay or run this snippet in the Inspector console to log the topmost element at the click coordinates:
// Inspector console
await page.mouse.move(100, 200); // approximate target coordinates
const top = await page.evaluate(() => document.elementFromPoint(100, 200));
console.log(top.outerHTML);
Common culprits: loading spinners with position:fixed; inset:0; z-index:9999, cookie banners, or toast containers. Fix by waiting for the overlay to disappear before the action.
5. Validate Network Hydration Timing
If the UI renders a skeleton or placeholder before API data arrives, the target button may exist but remain disabled. In the Trace Viewer Network tab, correlate the action timestamp with the API response that enables the button. Add an explicit wait for the data‑dependent state:
// Wait for the specific API response that populates the form
await page.waitForResponse(resp =>
resp.url().includes('/api/form-data') && resp.status() === 200
);
// Now the button should be enabled
await page.getByRole('button', { name: 'Submit' }).click();
Fixes Tied to Findings
| Finding | Fix | Risk / Limitation |
|---|---|---|
| Overlay blocking events | await expect(page.locator('.spinner')).toBeHidden({ timeout: 10000 }); before the action | Hard‑coded selector for spinner; if spinner class changes, wait fails silently |
| Element detached on re‑render | Migrate to getByRole(), getByLabel(), getByText(); avoid locator('css') for interactive elements | Requires accessible markup; may need data-testid fallback for non‑semantic elements |
| Animation/transition causing instability | await page.getByRole('button').click({ trial: true }); to verify actionability without side effects, or increase actionTimeout locally | trial only checks—does not execute; local timeout increase masks perf regressions |
| Disabled state until API returns | Wait for the enabling response (waitForResponse) or wait for toBeEnabled() assertion | Couples test to specific API endpoint; prefer waiting for visible UI state change when possible |
| Element hidden by CSS (zero size, opacity) | Fix application CSS or wait for the condition that reveals it (toBeVisible()) | Test‑side workarounds (scroll into view, force click) hide real UX bugs |
Escalation Criteria
Escalate to application developers (not test infrastructure) when:
- Trace shows the element never becomes actionable within a realistic user timeframe (e.g., button stays disabled >10s after API returns).
- Overlay/spinner has no programmatic dismissed event—test cannot reliably synchronize.
- Accessibility tree lacks roles/names needed for resilient locators; requires
data-testidpollution. - Flakiness persists across multiple CI runs with identical traces, indicating a race condition in the application's state management.
Practical Verification Checklist
- Run the test 5× locally with
--retries=0; confirm 100% pass rate after fix. - Run in CI with
--retries=2; verify no flaky retries over 3 consecutive builds. - Open the Trace Viewer for a passing run; confirm the actionability checks all show green with comfortable margins (e.g., stability achieved 200ms before click).
- Disable the fix temporarily (comment out the wait) and confirm the test fails deterministically—proves the fix addresses the root cause.
What Not to Do
- Increase global timeout in
playwright.config.ts(timeout: 120000). This masks the problem and slows the entire suite. - Use
waitForTimeout(5000)as a band‑aid. It bypasses Playwright’s event loop, introduces fixed delays, and fails when the system is slower than the constant. - Force clicks (
click({ force: true })) to bypass "not receiving events". This clicks elements a real user cannot interact with, producing false positives.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.