Skip to content

Blog / Flaky tests and debugging

Flaky tests and debugging

Playwright Auto-Waiting: What It Waits For and When You Still Have to Wait

Playwright waits for a lot, but not for everything. Here is the exact list, and what to do for the rest.

October 23, 2026 · Blendra Team · 14 min read

Playwright auto-waiting is the reason most Playwright tests need no sleeps. Before a click or a fill, Playwright checks that the element is ready and retries until it is. But it only waits for the things it checks. It does not wait for your list to finish loading, for a total to be worked out, or for a value that lives outside the page. This guide gives you the exact checks per action, shows each gap with a test that runs, and swaps waitForTimeout and networkidle for waits that hold up in CI.

Every example comes from one small project: a Node server with no dependencies, a handful of pages with deliberate delays, and six spec files. Blocks marked "Fragment" are excerpts from those files. The suite was run with @playwright/test 1.61.1 in October 2026: 36 tests, all passing, and still all passing when each test was repeated five times.

Key takeaways

  • Actions such as click and fill wait for the element to be visible, stable, enabled, editable and not covered, but each action runs only some of those checks. press, focus and dispatchEvent run none.
  • Reads like count(), allTextContents() and isVisible() answer right away. textContent() waits for the element, not for its final text.
  • Wait with a web-first assertion on the outcome: toHaveCount, toHaveText, toBeVisible. Use expect.poll for things outside the DOM.
  • waitForTimeout and networkidle are both discouraged in Playwright's own API docs. Two demos below show networkidle going wrong in both directions.
  • In Playwright Test, actions and navigations have no timeout of their own by default. Only the test timeout stops them, unless you set actionTimeout and navigationTimeout.

What actionability checks run before each action

When you call an action on a locator, Playwright first waits for the locator to find its element. Then it runs a set of actionability checks and retries them until they all pass or the action's timeout ends. The checks are:

  • Visible: the element has a non-empty bounding box and is not visibility: hidden. display: none and zero-size elements fail. opacity: 0 passes.
  • Stable: the bounding box is the same for at least two animation frames, so the element is not mid-animation.
  • Receives events: the element is the hit target at the click point. If an overlay would get the click, this check fails.
  • Enabled: not a disabled form control, not inside a disabled fieldset, and not inside an element with aria-disabled="true".
  • Editable: enabled and not readonly (or aria-readonly="true").

Not every action runs every check. Here is the matrix for the common ones:

Which actionability checks Playwright runs for click, fill, check and hover

Action Visible Stable Receives events Enabled Editable
click, dblclick, tap Yes Yes Yes Yes No
check, uncheck, setChecked Yes Yes Yes Yes No
hover, dragTo Yes Yes Yes No No
fill, clear Yes No No Yes Yes
selectOption Yes No No Yes No
press, focus, dispatchEvent, setInputFiles No No No No No

Two rows surprise people. fill doesn't check "stable" or "receives events", so it types into a field that sits under an overlay (a test in the example project proves it). And press, focus, dispatchEvent and setInputFiles run no actionability checks at all. They still wait for the element to exist: press on a field that is added 600 ms after the page loads waits for it, then types.

If a locator matches two or more elements, the action doesn't wait. It fails at once with a strict mode violation.

Watch the checks retry

This test has a button that is disabled for 800 ms. There is no wait in the test, and it passes:

// Fragment from tests/actionability.spec.ts
await page.setContent(`
  <button disabled onclick="this.textContent = 'Saved'">Save</button>
  <script>setTimeout(() => document.querySelector('button').disabled = false, 800);</script>
`);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('button')).toHaveText('Saved');

The same goes for an overlay that is removed after 800 ms, and for a readonly field that becomes editable. When a check never passes, the call log tells you which one failed. Here is the real output for a click on a button that sits under a full-page overlay, with a one-second timeout:

TimeoutError: locator.click: Timeout 1000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Pay' })
    - locator resolved to <button>Pay</button>
  - attempting click action
    2 × waiting for element to be visible, enabled and stable
      - element is visible, enabled and stable
      - scrolling into view if needed
      - done scrolling
      - <div id="overlay"></div> intercepts pointer events
    - retrying click action
    - waiting 20ms

Read the log from the bottom of each attempt. "intercepts pointer events" means the "receives events" check failed and names the element in the way. "element is not enabled" and "element is not editable" are the messages for the other two checks you will see most often.

force: true does not mean "click the button anyway"

When a click times out on an overlay, { force: true } looks like the fix. It skips the checks, but the mouse still goes down at the centre of the button, and the overlay is what sits there. This test proves it: the overlay records the click and the button's handler never runs.

// Fragment from tests/actionability.spec.ts
await page.setContent(`
  <button onclick="document.querySelector('p').hidden = false">Pay</button>
  <p hidden>Paid</p>
  <div id="overlay" onclick="this.dataset.clicked = 'yes'"
       style="position:fixed;inset:0;background:rgba(0,0,0,.4)"></div>
`);
await page.getByRole('button', { name: 'Pay' }).click({ force: true });
await expect(page.locator('#overlay')).toHaveAttribute('data-clicked', 'yes');
await expect(page.getByText('Paid')).toBeHidden();

dispatchEvent('click') does reach the button (another test in the example project shows that), but it fires a synthetic event on an element that a real user could not click. Both hide the actual problem. Find out why the overlay is there, and either wait for it to go or close it like a user would.

To know that a button is ready without pressing it, click({ trial: true }) runs the checks and skips the click.

A pop-up that never leaves: addLocatorHandler

Auto-waiting waits for an overlay to go away. A newsletter pop-up or cookie banner never goes away by itself, so the click times out. When it shows up at an unpredictable moment, register a handler with page.addLocatorHandler. Playwright checks for the locator before each actionability check and each auto-waiting assertion, and runs your handler when it is visible:

// Fragment from tests/actionability.spec.ts. On /promo the pop-up covers the page
// at 500 ms and the button is enabled at 800 ms, so the click always meets the pop-up.
await page.addLocatorHandler(page.getByRole('dialog', { name: 'Newsletter' }), async (dialog) => {
  await dialog.getByRole('button', { name: 'No thanks' }).click();
});
await page.goto('/promo');
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByText('Added to cart')).toBeVisible();

The same click without the handler fails with "intercepts pointer events". If the pop-up always appears at the same step, the Playwright docs recommend closing it in the test as a normal step instead.

What Playwright auto-waiting does not cover

Auto-waiting is a property of actions and web-first assertions. Everything else reads the page once and returns. On the example /products page, the list arrives 700 ms after load and the total is filled in 400 ms after that. All three of these tests pass, and all three show a test reading too early:

// Fragments from three tests in tests/gaps.spec.ts
await page.goto('/products');
const items = page.getByRole('list', { name: 'Products' }).getByRole('listitem');

expect(await items.count()).toBe(0); // the fetch is still in flight
expect(await items.allTextContents()).toEqual([]);
expect(await page.getByText('Desk lamp').isVisible()).toBe(false);
expect(await page.getByTestId('total').textContent()).toBe('Total: calculating…');

page.goto resolves on the load event by default. Data fetched by JavaScript after that is not part of it.

Here is what each kind of read does:

Method Waits for
count(), all(), allTextContents(), allInnerTexts() Nothing. Returns what is there now, even an empty list
isVisible(), isHidden() Nothing. Returns at once
textContent(), innerText(), getAttribute(), inputValue() The element to exist. Not for its text or value to change
isEnabled(), isChecked() and the other is…() reads of one element The element to exist, then reads once
page.evaluate(), request.get() and anything that isn't a locator Nothing

The reads in the third and fourth rows behave like actions in one way: they fail with a strict mode violation when the locator matches more than one element. tests/reads.spec.ts in the example project checks every locator row and page.evaluate().

The fix: wait on the outcome, then read

Put a web-first assertion in front of the read, or replace the read with the assertion:

// Fragments from three tests in tests/gaps.spec.ts
await expect(items).toHaveCount(3);
const names = await items.allTextContents(); // safe now
expect(names.toSorted()).toEqual(['Desk lamp', 'Fountain pen', 'Notebook']);

// Or one retrying check for the whole list, in order:
await expect(items).toHaveText(['Desk lamp', 'Notebook', 'Fountain pen']);

// A value that is worked out later:
await expect(page.getByTestId('total')).toHaveText('Total: $42.00');

toHaveCount(3) is enough when the list starts empty. When it starts with old content, wait for something only the new content has, as the search example below shows. The assertions guide covers which matcher proves what.

Checks outside the DOM

Some outcomes leave no trace on the page: a draft saved to localStorage, a row written to the database, an email queued by the API. No locator can wait for them. Use expect.poll, which retries any async function:

// Fragment from tests/gaps.spec.ts. /notes saves the draft 500 ms after the last key.
await page.goto('/notes');
await page.getByLabel('Notes').fill('Call the supplier');
await expect
  .poll(() => page.evaluate(() => localStorage.getItem('draft')), {
    message: 'draft saved to localStorage',
    timeout: 3_000,
  })
  .toBe('Call the supplier');

Replace waitForTimeout

The API docs for page.waitForTimeout say it "should only be used for debugging". A fixed sleep is too short on a slow CI machine and wasted time everywhere else. Every sleep stands in for a real event, so name the event and wait for it:

You were sleeping until… Wait for this instead
An element appears or changes text await expect(locator).toBeVisible() / toHaveText()
A list finishes loading await expect(items).toHaveCount(n) or toHaveText([...])
A spinner or dialog goes away await expect(locator).toBeHidden(), after a positive signal
A request finishes page.waitForResponse(...), started before the action
The URL changes await expect(page).toHaveURL(...) or page.waitForURL(...)
Something outside the page is true expect.poll(...)
A timer in the app (debounce, countdown) The outcome of the timer, or page.clock to control time

The /search page is a common case. It sends the request 300 ms after the last key, and the server takes 300 ms to answer. A waitForTimeout(1000) usually works, until the server is slow. Assert the result instead:

// Fragment from tests/replace-wait-for-timeout.spec.ts
await page.goto('/search');
await page.getByLabel('Search').fill('ap');
await expect(page.getByRole('list', { name: 'Results' }).getByRole('listitem')).toHaveText([
  'apple',
  'apricot',
]);

If you need the response itself, wait for it. Start waiting before the action, and match the exact request, not any search:

// Fragment from tests/replace-wait-for-timeout.spec.ts
const response = page.waitForResponse(
  (r) => r.url().includes('/api/search') && new URL(r.url()).searchParams.get('q') === 'b',
);
await page.getByLabel('Search').fill('b');
expect(await (await response).json()).toEqual(['banana', 'blueberry']);

Now the trap. The user searches for "a" (three results), then for "av". If the next line is await expect(results).toHaveCount(3), it passes at once on the old list. Wait for something only the new answer has:

// Fragment from tests/replace-wait-for-timeout.spec.ts
await page.getByLabel('Search').fill('a');
await expect(results).toHaveCount(3);
await page.getByLabel('Search').fill('av');
await expect(results).toHaveText(['avocado']); // only true for the second query

For a timer that is long on purpose, such as "resend the code in 60 seconds", don't wait at all. page.clock lets you install a fake clock and fast-forward it.

Is networkidle a good idea?

networkidle resolves when the page has had no network connections for at least 500 ms. Playwright's API reference marks it DISCOURAGED in goto, waitForLoadState and the other navigation methods. It is a guess about readiness, and it can be wrong in both directions.

It can resolve too early. The /late page is quiet for 1.5 seconds after load, then fetches its list. That quiet gap is all networkidle needs:

// Fragment from tests/networkidle.spec.ts
await page.goto('/late', { waitUntil: 'networkidle' });
const items = page.getByRole('list', { name: 'Recently viewed' }).getByRole('listitem');
expect(await items.count()).toBe(0); // "idle", and still empty
await expect(items).toHaveCount(3); // this is what actually waits

Debounced inputs, lazy loading on scroll, and data fetched after an animation all create gaps like this.

It can never resolve. The /live page polls a status endpoint every 250 ms, as many dashboards do. There is never a 500 ms gap:

// Fragment from tests/networkidle.spec.ts
await page.goto('/live');
const error = await page
  .waitForLoadState('networkidle', { timeout: 3_000 })
  .catch((e: Error) => e);
expect(String(error)).toContain('Timeout 3000ms exceeded');

Analytics beacons, chat widgets and long polling do the same in real apps. And even when it works, networkidle waits for every request on the page, including ones your test doesn't care about. So use it rarely, if ever. A web-first assertion on the thing you need, or waitForResponse for one known request, says what you mean.

locator.waitFor and when it is the right call

locator.waitFor() waits for one element to reach a state: visible (the default), hidden, attached or detached. It returns nothing and asserts nothing, so most of the time a web-first assertion is the better tool: it waits the same way and fails with a clearer message.

waitFor has three good uses:

  1. Before a read that does not wait. allInnerTexts() on a table that isn't there yet returns an empty list.
// Fragment from tests/wait-for.spec.ts
const table = page.getByRole('table', { name: 'Report' });
await table.waitFor(); // state: 'visible'
const cells = await table.getByRole('cell').allInnerTexts();
expect(cells).toEqual(['August', '182', 'September', '211']);
  1. Before something that isn't a locator action, such as page.mouse, page.keyboard or page.evaluate, which never wait for anything.
  2. For an element that is attached but never visible, such as a hidden input that a script fills in: waitFor({ state: 'attached' }).

The delayed spinner trap

The classic advice is "wait for the spinner to disappear". Many apps show a spinner only when loading takes longer than a few hundred milliseconds, so that fast loads don't flash. The example /report page waits 300 ms before it shows one. Right after goto, there is no spinner yet, so "detached" is already true:

// Fragment from tests/wait-for.spec.ts
await page.goto('/report');
const spinner = page.getByRole('progressbar', { name: 'Loading report' });
await spinner.waitFor({ state: 'detached' }); // returns at once: no spinner yet
expect(await page.getByRole('row').count()).toBe(0); // and the report isn't there

Waiting for the spinner to become visible first doesn't fix it either: on a fast run the spinner never appears, and that wait times out. Wait for the thing you came for:

// Fragment from tests/wait-for.spec.ts
const rows = page.getByRole('table', { name: 'Report' }).getByRole('row');
await expect(rows).toHaveCount(3); // header + 2 months
await expect(page.getByRole('progressbar')).toHaveCount(0);

One more rule: waitFor is strict. On a locator that matches three list items it throws "strict mode violation". If any match will do, use .first().

Timeouts: action, navigation, expect, test

Every wait above ends at a timeout. Playwright Test has several, and the defaults (from the timeouts page) are not what most people expect:

Timeout Default Set it for the project Set it for one call
Test 30 s timeout test.setTimeout(), or test.slow() to triple it
Expect 5 s expect: { timeout } { timeout } on the matcher
Action none use: { actionTimeout } { timeout } on click, fill, waitFor…
Navigation none use: { navigationTimeout } { timeout } on goto, waitForURL…
Global (whole run) none globalTimeout none

"None" means the action keeps retrying until the test timeout ends. That is why a stuck click in a 30-second test fails after 30 seconds with "Test timeout of 30000ms exceeded" rather than with a short, specific error. (In the Playwright library outside Playwright Test, including Playwright for Python, actions and navigations default to 30 seconds each.)

This is the config used by the example project:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  reporter: 'list',
  // Whole test, including beforeEach hooks and fixture setup (default 30 s).
  timeout: 30_000,
  // Every web-first assertion such as toHaveText or toBeVisible (default 5 s).
  expect: { timeout: 5_000 },
  // Optional cap on the whole run, useful in CI (default: none).
  globalTimeout: process.env.CI ? 20 * 60_000 : undefined,
  use: {
    ...devices['Desktop Chrome'],
    baseURL: 'http://127.0.0.1:4413',
    // Each click, fill, waitFor... (default: none, so only the test timeout stops them).
    actionTimeout: 10_000,
    // Each goto, reload, waitForURL, waitForLoadState (default: none).
    navigationTimeout: 15_000,
  },
  webServer: {
    command: 'node server.mjs',
    url: 'http://127.0.0.1:4413/products',
    reuseExistingServer: !process.env.CI,
  },
});

The difference shows in the failure. Both of these come from the same disabled button in demo/timeouts.demo.ts, a file that fails on purpose. With actionTimeout: 2_000:

TimeoutError: locator.click: Timeout 2000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Save' })
    - locator resolved to <button disabled>Save</button>
  - attempting click action
    2 × waiting for element to be visible, enabled and stable
      - element is not enabled

With no action timeout (the second demo test passes { timeout: 0 } to the click, which removes its own limit), the click runs until the 5-second test limit in that demo config:

Test timeout of 5000ms exceeded.

Error: locator.click: Test timeout of 5000ms exceeded.

Both point at the right line, but the first fails sooner and leaves the rest of the test budget for cleanup and traces. Raise the expect timeout for one slow assertion with { timeout } rather than for the whole project, and use test.slow() for a test that is slow by design. If you raise timeouts to stop failures, you are treating a symptom: the flaky tests guide shows how to find the cause with traces.

Blendra's exported timeouts

Blendra records a flow in Chrome and exports it as a Playwright TypeScript project. The exported suite relies on the waiting described in this post. Its playwright.config.ts sets a 60-second test timeout and a 5-second expect timeout. It sets no actionTimeout or navigationTimeout, so each action can wait up to the 60-second test limit. In CI, a failed test is retried twice, and a trace is kept on the first retry.

The generated step helpers never call waitForTimeout or networkidle. Where a recorded click led to a new page, the exported step waits up to 15 seconds for the recorded URL. Checks run through Playwright's expect, so each one retries for up to the 5-second expect timeout.

You own the exported code, so you can change any of these numbers in playwright.config.ts. The docs for the Playwright Python and Cypress exports (both in beta) list how their timeouts differ. When a run times out, start with the test timeouts troubleshooting page.

FAQ

Does Playwright wait for the page to load after a click?

Partly. click waits for any navigation it starts to either succeed or fail, and page.goto waits for the load event by default. Neither waits for data that JavaScript fetches afterwards. After a click that changes the page, assert something on the new page, such as await expect(page).toHaveURL(...) plus a heading or a row.

Does expect() auto-wait too?

Web-first assertions do: await expect(locator).toBeVisible(), toHaveText, toHaveCount, toHaveURL and the rest retry until they pass or the expect timeout ends. Generic matchers on plain values, like expect(await locator.count()).toBe(3), check once. The count was read before expect ran, so there is nothing to retry.

How do I wait for an element to disappear?

Use await expect(locator).toBeHidden() or await expect(locator).toHaveCount(0). But run it only after something proves the element had its chance to appear, such as a result that loads with it. Otherwise it passes before the element has even shown up, as in the delayed spinner example.

Should I set actionTimeout?

Usually yes, to a value well below the test timeout (for example 10 seconds against 30). Failures then name the step and the failed check sooner, and a stuck action can't use up the whole test.

Flaky testsPlaywrightTypeScript

Start with the flow you test by hand every release.

Start free →