How Playwright's auto-waiting actually works
Cypress retries commands; Playwright auto-waits on actionability. Same problem (DOM is async, tests are sync), different solution. Here's what Playwright is actually doing when you call .click().
If you've read how Cypress's retry-ability solves this same problem, you already understand the challenge: the DOM changes asynchronously, but test code reads it synchronously. The two frameworks solve it in architecturally different ways. Cypress retries the assertion chain. Playwright checks actionability before each action, repeatedly, until the element is ready.
The actionability model: five checks before every action
When you call locator.click(), Playwright doesn't fire the click immediately. It runs up to five checks on the target element, polling until they all pass:
- Attached — the element exists in the DOM (not detached)
- Visible — the element has a non-zero bounding box and is not
display:noneorvisibility:hidden - Stable — the element hasn't moved or resized in the last animation frame (catches elements mid-CSS-transition)
- Receives events — the element isn't covered by another element or blocked by a modal
- Enabled — the element isn't disabled (for form controls)
Not all five apply to every action. .fill() also checks editable — the element isn't read-only. .check() doesn't check stability the same way as .click(). But the core model is these five checks, evaluated in a polling loop.
// All of these auto-wait for actionability:
await page.locator('button').click(); // checks all 5
await page.locator('input').fill('text'); // adds editable check
await page.locator('select').selectOption('a'); // checks visible + enabledThe polling runs against the live DOM, up to the timeout (default 30 seconds). If the element becomes actionable before the timeout, the action fires. If it never becomes actionable, the test fails with a descriptive error: "Element is not visible", "Element is detached from DOM", etc.
The 30-second default and why it's a feature
The default actionTimeout in Playwright is 30 seconds. New users often lower it to 5 seconds because "30 seconds is too long for a test." That's usually a mistake.
The timeout isn't how long a single action takes — it's the upper bound before giving up. In fast local runs, clicks resolve in milliseconds. In CI with a loaded runner, cold app, and flaky network, 30 seconds is the margin that prevents false negatives from legitimate slow operations (file uploads, report generation, email confirmation flows).
The right way to tune timeouts is directionally:
- Lower the global timeout for actions in your critical path if your app is genuinely fast and you want failures to surface quickly.
- Raise it per-action for operations you know take time:
await page.locator('[data-testid="export"]').click({ timeout: 60_000 }). - Never set a global 5-second timeout as your first tuning decision. Investigate before you lower.
A test that fails at 5 seconds with "element not visible" in CI but passes at 30 seconds isn't a fast test — it's a flaky test with a low timeout.
Locator vs ElementHandle: why one auto-waits and the other doesn't
This distinction trips people up constantly. Locators auto-wait. ElementHandles do not.
// Locator — resolves lazily at action time, auto-waits
const button = page.locator('button[type="submit"]');
await button.click(); // auto-waits for actionability
// ElementHandle — resolves immediately, snapshot of the DOM at that moment
const handle = await page.$('button[type="submit"]');
await handle?.click(); // NO auto-waiting — fires immediately or throwsAn ElementHandle is a direct reference to a DOM node captured at call time. If the node is removed and re-rendered (common in React after state updates), the handle is stale and the click throws. A Locator is a description — a way to find elements — re-evaluated at the moment of each action.
The Playwright docs say to prefer Locators and they're right. The only reason to use ElementHandle today is interoperability with third-party libraries that require a DOM node handle. For your own test code, always reach for Locator.
// Prefer this
const modal = page.locator('[data-testid="confirm-modal"]');
await modal.locator('button', { hasText: 'Confirm' }).click();
// Avoid this
const modal = await page.$('[data-testid="confirm-modal"]');
const button = await modal?.$('button:has-text("Confirm")');
await button?.click(); // stale risk, no auto-waitWhen waitForLoadState is justified
page.waitForLoadState() waits for the page to reach a network state: 'load', 'domcontentloaded', or 'networkidle'. It's tempting to scatter these after navigations. Resist.
Playwright's locator actions already wait for the specific element to be ready. Adding waitForLoadState('networkidle') before every action says "wait until there's been 500ms of network silence." That works, but it's slow — every navigation takes at least 500ms extra — and it's fragile with apps that poll.
The cases where waitForLoadState is genuinely justified:
// Justified: waiting for a download to complete (no visible DOM change)
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[data-testid="export-csv"]').click(),
]);
// Justified: after a full page navigation where you need the initial HTML
await page.goto('/dashboard');
await page.waitForLoadState('domcontentloaded');
// Now safe to call page.content() or evaluate JS
// NOT justified: before locator actions
await page.waitForLoadState('networkidle'); // usually unnecessary
await page.locator('[data-testid="submit"]').click(); // already waitsIf you find yourself adding waitForLoadState to stop flaky tests, the root cause is usually an assertion that runs before the relevant data loads. Add a proper expect(locator).toBeVisible() assertion on the element that signals "data is ready" instead.
Comparison sidebar: Cypress retry-ability vs Playwright auto-waiting
Both frameworks solve "the DOM is async" with polling. The architectures differ:
Cypress retries the entire assertion chain from the nearest retryable command. The assertion drives retrying — a command without a following assertion runs once. This means retry-ability is implicit and can be accidentally broken (by .then() callbacks, .each() on empty lists).
Playwright polls at the action level. Every action auto-waits for the specific element to be ready, independently of what comes before or after. There's no "retry chain" to accidentally break. The tradeoff is that assertions (expect(locator).toBeVisible()) also poll — Playwright uses the same wait mechanism for both actions and assertions.
The practical difference: Playwright's auto-waiting is harder to break accidentally. Cypress's retry-ability is more visible — you can see the retry loop in the command log. Neither is strictly superior for all teams; they reflect different design priorities.
Understanding both models makes you a better debugger. When a test fails in Playwright with "element not visible after 30s", you know Playwright polled all five actionability checks and the element never passed them — which points to visibility, covering elements, or disabled state. That's a specific, actionable error. Work backwards from which check failed.
// related
How Cypress retry-ability really works
Cypress retries commands until they pass or time out — but only some commands, and only some of the time. Understanding which is the difference between solid tests and flaky ones.
Playwright vs Cypress in 2026: an honest comparison
After shipping production suites in both, here's the honest breakdown — where Playwright pulls ahead, where Cypress still wins, and the single factor that should actually decide it.