Diagnose Timeout Triggers: Network vs. DOM vs. Configuration #
Before adjusting thresholds, isolate the failure vector. Auto-wait timeouts stem from delayed hydration, an unhandled network request, or a misaligned global timeout. The trace viewer (npx playwright show-trace trace.zip) shows exactly which actionability check never passed, and page.on('console') surfaces overlay or error noise. Cross-reference the network waterfall against DOM readiness to place the blame precisely.
Optimize Global & Per-Action Timeout Thresholds #
Global timeouts apply uniformly and mask localized bottlenecks. Scope thresholds to the action that needs them with locator.click({ timeout: 5000 }), keeping interaction timeouts tight to catch regressions while allowing longer navigation budgets for heavy SPAs.
Implement Custom Wait Strategies for Async State #
When auto-waiting cannot express the readiness condition — a framework hydration flag, a computed total, a settled list — replace it with an explicit predicate. expect(locator).toBeVisible() retries an assertion, and page.waitForFunction() runs a condition in the browser until it holds.
// playwright.config.ts — scoped global defaults, not a blanket 30s
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
actionTimeout: 5000, // each click/fill — tight enough to catch regressions
navigationTimeout: 10000, // page.goto and friends — SPAs need headroom
},
});
// Per-action override wins over the global for this call only.
test('submit form with tight timeout', async ({ page }) => {
await page.goto('/form');
await page.locator('#submit-btn').click({ timeout: 3000 }); // trade-off: strict, fails fast on a regression
});
// Explicit state wait — runs the predicate in the page until it is true.
await page.waitForFunction(() => {
const el = document.querySelector('#dynamic-content');
return el !== null && el.textContent?.includes('Loaded'); // condition auto-wait cannot express
});
await expect(page.locator('#dynamic-content')).toBeVisible({ timeout: 8000 });
Common Pitfalls #
- Raising global timeouts to mask async races instead of diagnosing them.
- Using
page.waitForTimeout()for hard delays instead of state-based waits. - Overriding actionability with
{ force: true }, clicking elements that should not be interactable. - Ignoring CSS transitions or layout shifts that temporarily block interaction.
Reliability Targets #
| Metric | Target | How to hit it |
|---|---|---|
| Timeout failure rate | < 2% of runs |
Scope timeouts, fix the real actionability blocker |
| Auto-wait bypass frequency | Trending down | Prefer auto-wait/expect over explicit waitForFunction where possible |
| CI feedback latency | Stable post-tuning | Tight interaction timeouts, longer nav only where needed |
| Flake resolution time (MTTR) | < 1 day |
Trace-driven diagnosis instead of blind raises |
FAQ #
Why does Playwright time out even when the element is visible?
Visibility is one of four checks. The element must also be stable (no running animation), actionable (not overlaid, no pointer-events: none), and attached. The trace viewer shows which check never passed.
Should I disable auto-waiting for faster execution? No — it is Playwright’s core reliability engine. Scope timeouts and add explicit waits for framework-specific hydration instead.
How do I tell a flaky test from a genuine timeout?
Run with --repeat-each=5 and capture traces. Consistent failure at the same DOM state is a config or app issue; intermittent pass-on-retry points to a race or environment drift.
Auto-Waiting Does Not Cover Application State #
The most common misunderstanding about built-in waiting is what it guarantees. It ensures the element is ready to receive an action; it says nothing about whether the application is ready for that action to make sense.
A submit button can be attached, visible, stable and enabled while the form behind it is still loading its options, its validation rules have not arrived, or a previous request is still in flight. Clicking then is entirely valid from the runner’s perspective and produces a result the test did not intend — a submission with incomplete data, or a click that is swallowed by a handler still being attached.
The gap is filled by asserting application state before acting, using whatever signal the interface publishes: a settled data attribute, a rendered value only present once data has loaded, or a control that becomes enabled only when the form is ready. Each of those is a claim about the application rather than about the DOM node, which is the level the test actually depends on.
This also explains a pattern that confuses people: adding a wait before the click fixes the test, so it looks like a timing bug in the runner. It is not — the wait is standing in for an application-readiness condition that nobody made observable, and it will fail again as soon as that state takes slightly longer.
// The element being actionable is not the application being ready.
// Trade-off: this needs a readiness signal from the app; without one, the test
// is inferring readiness from something merely correlated with it.
await expect(page.getByTestId('checkout-form')).toHaveAttribute('data-state', 'ready');
await page.getByRole('button', { name: 'Complete purchase' }).click();
Reading Which Actionability Check Failed #
Auto-waiting is not one condition but several, evaluated in sequence before an action is dispatched: the element must be attached, visible, stable, able to receive events, and enabled. A timeout means one of those never became true, and the error text names which — information that is routinely skipped in favour of raising the timeout.
Not visible usually means an ancestor is hidden or the element has an empty bounding box, which is common when a parent collapses to zero height. It rarely means “still loading”.
Not stable means the bounding box was still changing: an animation, a lazily loaded image shifting the layout, or a virtualised list settling. Raising the timeout helps only if the movement eventually stops, and does nothing for an animation that loops.
Intercepts pointer events is the most informative of all: the element is there and something else is on top. The message names the intercepting element, which is usually a backdrop, a sticky header, a cookie banner or a chat launcher.
Not enabled is a genuine application-state condition, and the right response is to wait for whatever should enable it rather than for time to pass.
Matching the response to the specific check turns a generic timeout into a targeted fix, and it explains why the same timeout increase resolves one failure and not another.
// Assert the specific precondition so the failure message names the problem.
// Trade-off: two extra lines per interaction, and a failure that explains itself.
const submit = page.getByRole('button', { name: 'Complete purchase' });
await expect(submit).toBeInViewport();
await expect(submit).toBeEnabled();
await submit.click();
When the Timeout Is the Right Change #
Raising a timeout is not always wrong, and treating it as always wrong leads to elaborate workarounds for what is genuinely a slow operation.
It is the right change when the operation is legitimately long and its duration is understood: a report that aggregates a large dataset, an import that processes a file, a cold start on a runner that provisions a container per job. In those cases a per-assertion timeout scoped to that specific step is precise and honest — and far better than raising the global default, which slows every unrelated failure to the same duration.
It is the wrong change when it is a response to a failure whose cause has not been identified. The signature is a timeout that gets raised, works for a few weeks, and is raised again: each increase buys time proportional to how much slower the runner has become, without addressing why the margin was thin.
The number that distinguishes the two cases is headroom — median duration divided by the configured timeout. An operation sitting at ten percent of its budget that occasionally fails has a race or a wrong wait condition; one sitting at sixty percent is genuinely close to its limit and deserves either a larger budget or a faster path. Measuring before adjusting takes a few minutes and prevents the ratchet.
Measure Before Adjusting #
Before changing any timeout, record what the operation actually takes across a few hundred runs. A number derived from the 95th percentile is defensible and stable; one chosen because it made the failure stop is neither.
When a timeout is genuinely required, scoping it to the single assertion that needs it keeps every other failure fast. A raised global default slows every unrelated failure by the same amount, which is a cost paid on every red run forever.
Record what an operation actually takes before changing what it is allowed to take.