Playwright Auto Wait & Synchronization: waitForSelector Explained

Most flaky UI tests fail for one reason: the test acts before the page is ready. Playwright's answer is auto-wait — actions wait until an element is actually usable, and assertions keep retrying until they pass. Used well, it removes almost all manual waiting code. This guide explains exactly what Playwright waits for, what it doesn't wait for, and which waits to use for the remaining cases.

Advertisement

What Is Auto-Wait?

Before an action such as click() or fill(), Playwright checks that the element is ready and keeps re-checking until it is — or until the timeout expires. Web-first assertions such as expect(locator).toHaveText() work the same way: they retry until the condition is true.

Manual-testing analogy: a person doesn't click "Submit" while the page is still loading or the button is grayed out — they wait until it's clickable. Playwright does the same thing automatically, on every step.

Interview answer: "Playwright performs actionability checks before every action — the element must be attached, visible, stable, enabled and able to receive the click — and web-first assertions retry until the expected state appears. That's why Playwright tests rarely need explicit waits or sleeps."

Actionability Checks: What Actions Wait For

Check Meaning Applies to
Attached / resolved The locator matches exactly one element in the DOM All actions
Visible It has a size and isn't hidden click, fill, hover, check…
Stable Not moving or animating click, hover, check…
Receives events Not covered by another element (overlay, spinner) click, hover, check…
Enabled Not disabled click, fill, check, selectOption…
Editable Not read-only fill, type

If a check never passes, the action fails with a timeout that says which condition was missing — for example "element is not enabled" or "another element would receive the click".

Auto-Wait in Action

A button starts a two-second operation with a spinner, then shows a message — no waits written:

import { test, expect } from '@playwright/test';

test('auto-wait handles a delayed button and message', async ({ page }) => {
  await page.setContent(`
    <button id="start">Start</button>
    <div id="spinner" hidden>Loading…</div>
    <div id="result"></div>
    <script>
      document.getElementById('start').onclick = () => {
        const s = document.getElementById('spinner');
        s.hidden = false;
        setTimeout(() => {
          s.hidden = true;
          document.getElementById('result').textContent = 'Order placed';
        }, 2000);
      };
    </script>`);

  await page.getByRole('button', { name: 'Start' }).click();       // waits until actionable
  await expect(page.locator('#spinner')).toBeHidden();              // retries until hidden
  await expect(page.locator('#result')).toHaveText('Order placed'); // retries until the text appears
});

The test is self-contained — copy it into any Playwright project and run it. In Selenium, the same flow needs explicit waits for invisibility and text.

Web-First Assertions Retry; Plain Checks Don't

// ✅ Retries until true (default 5 s)
await expect(page.locator('#result')).toHaveText('Order placed');

// ❌ Reads once — fails if the text hasn't arrived yet
expect(await page.locator('#result').textContent()).toBe('Order placed');

The second form is the most common source of flakiness in Playwright suites: await locator.textContent() or isVisible() returns the value right now. Use expect(locator) assertions — toBeVisible, toHaveText, toHaveCount, toHaveValue, toBeEnabled — whenever you check the page. More: Playwright Assertions.

What Auto-Wait Doesn't Cover

Auto-wait makes each step wait for its own element. It doesn't know about your application's business logic. These cases need an explicit, condition-based wait:

await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL(/dashboard/);   // web-first assertion on the URL
// or
await page.waitForURL('**/dashboard');

page.goto() waits for the page's load event, but a click that triggers a redirect or a single-page-app route change doesn't wait for the new page — assert on the URL or on an element of the next page.

A specific API response

const responsePromise = page.waitForResponse(
  r => r.url().includes('/api/search') && r.status() === 200
);
await page.getByPlaceholder('Search').fill('laptop');
await responsePromise;
await expect(page.getByTestId('result-row')).toHaveCount(3);

Start waiting before the action that triggers the request, so a fast response isn't missed.

Slow operations and polling

await expect(page.getByText('Report ready')).toBeVisible({ timeout: 30_000 });   // one slow step

await expect.poll(async () => (await page.request.get('/api/job/42')).status(),
                  { timeout: 20_000 }).toBe(200);                                 // poll any value

await page.locator('#submit').waitFor({ state: 'visible' });                      // wait without acting

Waits to Avoid

Wait Why to avoid it Use instead
page.waitForTimeout(3000) Always waits the full time; still fails when the app is slower A web-first assertion on the condition you need
waitForLoadState('networkidle') Many apps never go idle (polling, analytics, websockets); the Playwright docs discourage it for testing Wait for the element or response that proves readiness
page.waitForSelector() Still works, but it's the older, element-handle-based style locator.waitFor() or expect(locator).toBeVisible()
force: true on clicks Skips actionability checks and can hide real bugs (e.g. a covered button) Fix what's blocking the element

waitForTimeout vs a condition wait — the bus analogy: waiting a fixed ten minutes wastes time when the bus comes early and fails when it's late; waiting until the bus arrives is always right. Keep waitForTimeout for debugging only.

Timeouts You Can Configure

Setting Default Controls
timeout (test) 30 s The whole test
expect.timeout 5 s How long assertions retry
use.actionTimeout No limit (bounded by the test timeout) Each action's auto-wait
use.navigationTimeout No limit (bounded by the test timeout) goto, waitForURL

Raise the timeout on the one slow step ({ timeout: 30_000 }) rather than globally — a big global timeout makes every real failure slow to report.

Coming From Selenium

Selenium Playwright
elementToBeClickable(...) then click() locator.click() — built in
visibilityOfElementLocated expect(locator).toBeVisible()
invisibilityOfElementLocated expect(locator).toBeHidden()
textToBePresentInElement expect(locator).toHaveText()
urlContains expect(page).toHaveURL(/…/)
Thread.sleep page.waitForTimeout — avoid in both

Convert more of your Selenium waits in the Selenium ⇄ Playwright ⇄ Cypress Translator, and compare with Selenium Waits.

From Real Projects

I've built my automation skills on Selenium with Java and TestNG, and that background maps well onto Playwright: page classes become page objects, TestNG groups and parallel runs become projects and workers, and explicit waits become auto-waiting and web-first assertions. If you're coming from Selenium too, focus on those differences first. Testing Testsigma — a platform where users write automated tests in plain English — meant thinking like its users, who are testers themselves. Use web-first assertions; values read once don't wait for the page. Further reading: JavaScript for Playwright Automation.

📚 Official documentation: Selenium docs: Waiting strategies · Selenium documentation

FAQs

What is auto-wait in Playwright?

Built-in waiting: actions wait until the element passes actionability checks, and web-first assertions retry until the expected condition is met or the timeout expires.

What does Playwright wait for before a click?

That the locator resolves to one element that is visible, stable, enabled and not covered by another element.

Do Playwright assertions wait automatically?

Web-first assertions on a locator or page (expect(locator).toHaveText(), expect(page).toHaveURL()) do. Assertions on a plain value you've already read (expect(text).toBe()) don't.

Does Playwright wait for navigation after a click?

Not reliably — assert on the new URL with toHaveURL or waitForURL, or on an element of the next page.

waitForTimeout vs waitForSelector?

waitForTimeout waits a fixed time and should be avoided; waitForSelector waits for an element state, but the modern equivalents are locator.waitFor() and web-first assertions.

How does Playwright handle single-page-app transitions?

There's no page load to wait for, so wait for what changes — the URL (toHaveURL), a new element, or the API response the route depends on.