Quality Engineering
min read
Last update on

Playwright auto-waiting: what it checks before every click

Playwright auto-waiting: what it checks before every click
Table of contents

You know exactly how this goes. You write a test. It passes once, then fails the next morning.

TimeoutError: Waiting for selector ".submit-btn" failed

You add sleep(1000). It passes. You push it. Fails on a slow machine. You bump it to sleep(3000). Now your suite takes 12 minutes. Still fails on staging when the server is having a bad day. You bump it again.

I've been in that loop. The problem isn't the numbers. The root cause is a mismatch: your test runs at code speed, but your UI moves at human speed. Buttons appear after API responses. Modals open at varying times. Form fields stay disabled until validation passes. Every one of those gaps is waiting to break your test.

The playwright's answer isn't a bigger sleep. It's auto-waiting and once you understand how it actually works, you'll know what to do the next time a test breaks instead of just guessing at the number.

By the end of this, you'll know exactly which of five checks failed when Playwright times out and the fix will almost never be a bigger sleep.

TLDR

Playwright checks five things before every action  attached to DOM, visible, stable, enabled, not covered  automatically, on a retry loop every ~100ms up to 30 seconds. When something fails, the error tells you which check timed out. Read it. The fix is almost always one line.

What auto-waiting actually is

When you write this:

await page.getByRole('button', { name: 'Submit' }).click();


The playwright doesn't fire a click event straight away. It runs a polling loop checking every ~100ms, up to 30 seconds and only fires when the element passes all five actionability checks.

It's not magic. It's a retry loop with smart conditions built in. Every action — click, fill, check, hover, selectOption, dragTo — runs this loop automatically.

The default timeout is 30 seconds. You can override it per-action or globally:

// Per-action
await page.getByRole('button').click({ timeout: 5000 });


// Global config
export default defineConfig({
use: { actionTimeout: 10_000 },
});

What each actionability check actually does

Let's go through each check, because knowing them is what turns a confusing timeout into a one-line fix.

1. Attached to the DOM

The element must exist in the document  not hidden via CSS, but actually present in the DOM tree.

// Waits until the element is rendered into the DOM
await page.getByTestId('user-card').click();

This handles lazy-loaded components, skeleton loaders replacing real content, and anything injected by JavaScript after the page loads.

2. Visible

The element must be visible. Playwright checks:

  • Display is not none
  • Visibility is not hidden
  • Opacity is not 0
  • The element has a non-zero bounding box (width > 0, height > 0)
// Fails if the modal is hidden; waits if it's animating in
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();

3. Stable (not animating)

If an element is sliding in, fading up, or otherwise moving, Playwright waits for it to stop. It does this by comparing the element's bounding box across two consecutive frames. If the position changed, it's unstable keep waiting.

// The dropdown animates open. Playwright waits for it to settle.
await page.getByRole('listbox').getByText('India').click();

In practice, this is the check that trips people up most on component libraries with heavy CSS transitions. The element is visible but the playwright won't touch it because it's still moving.

4. Enabled

Form elements input, button, select, textarea must not be disabled.

// Waits until the submit button becomes enabled after form validation passes
await page.getByRole('button', { name: 'Submit' }).click();

// Waits until the submit button becomes enabled after form validation passes

await page.getByRole('button', { name: 'Submit' }).click();

5. Editable (for fill and type)

For input actions, Playwright also checks that the element is not readonly and not disabled.

// Waits for the OTP field to become editable after the SMS is sent
await page.getByLabel('Enter OTP').fill('123456');

How Playwright compares to Selenium and Cypress

Selenium makes you write every wait yourself:

await driver.wait(until.elementLocated(By.css('.submit-btn')), 10000);
await driver.wait(until.elementIsVisible(
driver.findElement(By.css('.submit-btn'))), 5000);
await driver.wait(until.elementIsEnabled(
driver.findElement(By.css('.submit-btn'))), 5000);
const btn = await driver.findElement(By.css('.submit-btn'));
await btn.click();

Cypress is better , it retries get() automatically but you still declare the conditions yourself:

cy.get('.submit-btn', { timeout: 10000 })
.should('be.visible')
.should('not.be.disabled')
.click();


Playwright:

await page.getByRole('button', { name: 'Submit' }).click();

One line. All five checks happen automatically.

When auto-waiting saves you without extra code

Scenario 1: Button appears after an API response

await page.getByRole('button', { name: 'Load More' }).click();
// Waits until the new items actually appear in the DOM
await expect(page.getByTestId('product-card')).toHaveCount(20);

No sleep. No explicit wait. The assertion retries until it's true.

Scenario 2: Multi-step form with dependent fields

// Country field enables State field
await page.getByLabel('Country').selectOption('India');
// State dropdown is disabled until country is selected
// Playwright waits for it to become enabled automatically
await page.getByLabel('State').selectOption('Karnataka');
// Submit is disabled until both are filled
await page.getByRole('button', { name: 'Continue' }).click();

Where auto-waiting won't save you

These are the three cases where you need to help Playwright along.

Case 1: Element is visible but covered by an overlay

The cookie banner and chat widget problem. The element is in the DOM and visible but something is sitting on top of it.

Error: locator.click: Element is not visible
Locator: getByRole('button', { name: 'Sign Up' })
Element: visible
Covered by: <div id="chat-widget" />

await page.locator('#chat-widget .close-btn').click();
await page.getByRole('button', { name: 'Sign Up' }).click();

Case 2: Element that never becomes stable

Some animations loop indefinitely spinners, pulsing indicators. Playwright waits for stability and eventually times out.

// Clicking inside a looping spinner will always timeout
await page.locator('.loading-spinner .inner-text').click();



// Wait for the spinner to disappear first
await expect(page.locator('.loading-spinner')).not.toBeVisible();
await page.locator('.inner-text').click();

Case 3: Page navigations need explicit waits

After a form submit, Playwright doesn't automatically wait for the page to fully navigate before running the next action.

// This might proceed before the page fully navigates
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).waitFor(); // might fail



// Better: wait for the URL change explicitly
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('**/dashboard');
await page.getByRole('heading', { name: 'Dashboard' }).waitFor();

Note: waitForNavigation is soft-deprecated in Playwright v1.26+. Use waitForURL() or waitForLoadState() instead.

How to debug a failed actionability check

When Playwright times out, here's the actual workflow:

Step 1 - Read the error

Playwright's error messages are specific. "Element is not visible", "Element is covered by", "Element is not stable" each one points to a different check. Don't jump to a fix before you read which check failed.

Step 2 - Run with --headed and --slowmo

Watch what actually happens in the browser. Nine times out of ten, you'll see exactly what's blocking the element.

npx playwright test --headed --project=chromium -- test-name

Step 3 -  Fix the root cause, not the symptom

If the element is covered, dismiss the overlay. If it's not stable, wait for the animation to finish. If it's disabled, wait for the condition that enables it. Never reach for sleep() to patch a timing issue you don't understand yet.

FAQ

Does auto-waiting work with iframes and shadow DOM?

Playwright's locators handle shadow DOM natively. For iframes, use frameLocator() to scope your locator auto-waiting then applies to the element within that frame.

What happens when the 30-second timeout expires?

Playwright throws a TimeoutError and the test fails at that step. To retry the whole test, configure retries in playwright.config.ts.

Can I disable auto-waiting for a specific action?

Not directly. You can set a very low timeout to fail fast, but there's no flag to skip actionability checks entirely.

How is expect().toBeVisible() different from the auto-waiting visibility check?

The actionability check runs internally before actions like click and fill. expect().toBeVisible() is an explicit assertion that also retries use it when you need to assert state without triggering an action.

Why do my tests pass locally but fail in CI?

Usually two causes: CI machines are slower and hit the default 30s timeout, or overlays (cookie banners, chat widgets) appear in the test environment but not locally. Check which actionability check failed that tells you which environment difference to investigate.

Does auto-waiting apply to page.evaluate() calls?

No. page.evaluate() runs JavaScript directly in the browser and bypasses all actionability checks.

Conclusion

The next time a test fails and someone reaches for sleep(3000), stop. Read the error. Playwright is telling you exactly which check failed attached, visible, stable, enabled, or covered. The fix is almost always one line that addresses the actual condition, not a number that masks it.
sleep() was always a guess. Auto-waiting is Playwright already knowing how to wait. Let it.

Written by
Editor
No art workers.