Skip to main content
Back to Blog

Playwright Auto-Waiting vs Business-State Waiting

Playwright's six actionability checks tell you an element is ready to click. They say nothing about whether the order was placed. Most flaky waits are that second question answered with the first question's tool.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Aug 31, 2026Updated Sep 7, 20267 min read
Abstract diagram comparing two timelines: an upper track that stops early when the element becomes actionable, and a lower track that keeps waiting until a confirmed business state is reached, with the gap between them marked.
Quick answer: Playwright's auto-waiting resolves whether an element is ready to be acted on. It says nothing about whether your system has finished the work. Those are different questions, and most "flaky" waits are the second question answered with the first one's tool.

A test clicks Submit, the click succeeds, the next assertion fails, and someone adds a two-second sleep. It passes. Six weeks later it fails again on a slower CI runner, and the sleep becomes four seconds.

The sleep was never the fix, because the click was never the problem. Playwright waited correctly — for the button. Nobody waited for the order.

What auto-waiting actually guarantees

Before every action, Playwright runs a set of actionability checks against the target element. There are six, and it is worth knowing them by name because each one maps to a class of failure you will eventually debug:

  • Attached — the element is present in the DOM.
  • Visible — it has a non-empty bounding box and is not visibility: hidden.
  • Stable — its bounding box has not changed across two consecutive animation frames.
  • Enabled — it is not disabled.
  • Editable — it is enabled and not readonly.
  • Receives events — it is the hit target at the action point, so no overlay will swallow the click.

Which checks run depends on the action. click(), dblclick(), hover(), tap(), check(), and uncheck() require visible, stable, enabled, and receives-events. fill() requires visible, enabled, and editable — not stable, and not receives-events.

Notice what is absent from that list: any notion of your application's state. Playwright has no way to know that clicking Submit starts a payment authorisation, or that the row will only appear after a queue worker picks up a job. It knows about pixels and DOM properties. That is the whole contract, and it is a good contract — it just is not the one people assume.

The case auto-waiting handles beautifully

The documentation's own example is worth internalising, because it shows how much auto-waiting does cover. If your page disables the Sign Up button while it checks whether a username is unique, then replaces it with an enabled one, Playwright waits and clicks the enabled button. You do not need to write anything for that:

// No wait needed. Playwright retries actionability until the button is enabled.
await page.getByRole('button', { name: 'Sign up' }).click();

This is the class of problem auto-waiting solves, and it solves it completely. Adding a sleep here is pure superstition.

Where the gap opens

The gap appears the moment the thing you care about is not a property of the element you just touched. Three shapes cover most of it.

Asynchronous work behind the click. The button re-enables as soon as the request is sent, not when the job completes. The element is actionable and the system is not done.

State that lands somewhere else. You click Save in a dialog and assert on a table behind it. Nothing about the Save button's actionability tells you the table has re-rendered.

State that is not in the DOM at all. A webhook fired, a row was written, an email was queued. No amount of DOM waiting will observe it.

Wait on the outcome, not the clock

The fix in every case is the same in shape: assert on the observable fact that means the work is done. Playwright's web-first assertions retry automatically until they pass or time out, which makes them the right primitive:

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

// Wrong: guesses at duration, and encodes the guess as a constant
await page.waitForTimeout(2000);
await expect(page.getByText('Order confirmed')).toBeVisible();

// Right: the assertion retries until the business fact is true
await expect(page.getByRole('status')).toHaveText(/Order [A-Z0-9]{8} confirmed/);

page.waitForTimeout() is the one to remove on sight. Playwright's own documentation describes it as discouraged for anything but debugging. A sleep is a bet that the system is slower than X and faster than the test timeout, and the bet is re-run on every machine your suite touches.

Waiting on the network boundary

When the meaningful event is a request completing rather than a pixel changing, wait for the request. Set the waiter up before the action, or you will race it:

// Arm the waiter first - awaiting it after the click can miss the response
const orderCreated = page.waitForResponse(
  (response) =>
    response.url().includes('/api/orders') &&
    response.request().method() === 'POST' &&
    response.status() === 201,
);

await page.getByRole('button', { name: 'Place order' }).click();
const response = await orderCreated;

const { id } = await response.json();
await expect(page.getByRole('heading', { name: `Order ${id}` })).toBeVisible();

This is stronger than a UI-only assertion in one specific way: it tells you which boundary broke. If the response never arrives, the backend or the request is at fault. If it arrives and the heading never appears, the rendering is at fault. A single "text never appeared" timeout cannot distinguish those.

When the fact is not in the browser

For state that only exists server-side, poll the source of truth with a retrying assertion rather than sleeping and hoping:

// expect.poll retries the function until the assertion passes or times out
await expect
  .poll(async () => {
    const res = await request.get(`/api/orders/${orderId}`);
    return (await res.json()).status;
  }, {
    message: 'order should reach FULFILLED after the worker runs',
    timeout: 30_000,
  })
  .toBe('FULFILLED');

Use expect.toPass() when the thing you need to retry is a whole block of assertions rather than a single value. Both give you a named, bounded wait with a failure message that says what was expected — which is exactly what a sleep denies you.

The two options that hide bugs

Two escape hatches deserve a specific warning, because both convert a real failure into a green run.

force: true skips the actionability checks entirely. If a click only works with force, something is covering your element — a modal backdrop, a sticky header, a cookie banner. Those are the exact conditions a real user would hit. Forcing the click asserts that your test can reach the element, not that a user can.

// Hides an overlay bug
await page.getByRole('button', { name: 'Save' }).click({ force: true });

// Names it instead
await expect(page.getByTestId('cookie-banner')).toBeHidden();
await page.getByRole('button', { name: 'Save' }).click();

Raising the timeout is the other one. A timeout increase is appropriate when the operation is genuinely slow and you know why — a report build, a cold Lambda. It is not appropriate as a first response to intermittency, because it changes how long you wait to learn you have a bug, not whether you have one.

There is a legitimate use for trial: true, though: it runs the actionability checks and skips the action, which lets you assert readiness without side effects.

A rule that survives code review

For every wait in a test, you should be able to finish this sentence: "this waits until ______, which is true exactly when ______ has happened."

A sleep cannot finish it. toBeVisible() on a confirmation region can. waitForResponse on a specific status code can. If the sentence needs the word "usually" or "should be enough", the wait is a guess wearing a timeout's clothing.

A practical review heuristic: grep your suite for waitForTimeout and force: true. Each hit is either a documented, justified exception or an unlogged bug. There is rarely a third category.

AI can remove the sleeps; it cannot define “done”

A model is genuinely good at the mechanical half of this: finding every sleep in a suite, proposing the retrying assertion that replaces it, rewriting a waitForTimeout into a waitForResponse with the right predicate shape.

It cannot tell you what "done" means for your system. Whether an order is finished when the API returns 201, when the worker flips the status, or when the confirmation email is queued is a product decision. A model asked to make a test pass will reliably choose whichever definition passes — which is how you end up with a green suite asserting the weakest possible fact.

Let the model do the mechanical rewrite. Keep the definition of done with the person who owns the requirement.

Apply this now

Pick the flakiest test you have. Find every wait in it and write the "waits until ______, true exactly when ______" sentence for each. Replace the ones that cannot be completed with a retrying assertion on an observable outcome. Then run the test twenty times on your slowest environment, not your laptop.

If it still fails intermittently, you have learned something more valuable than a passing test: the flakiness is in the system, not the wait.

Frequently asked questions

If Playwright auto-waits, why do I ever need to wait explicitly?

Because auto-waiting is scoped to the element you are acting on. It cannot know that a background job, a second component, or a server-side write is the thing you actually care about.

Is waitForTimeout ever acceptable?

For debugging, yes — pausing to look at a page is a legitimate use. In a committed test, treat it as a defect. Playwright's documentation itself discourages it outside debugging.

Should I use an assertion or an explicit wait?

Prefer the assertion. Web-first assertions retry and fail with a message describing what was expected. An explicit wait that is not also an assertion gives you a timeout with much less context.

When is force: true justified?

When you are deliberately testing behaviour that a real pointer cannot reach and you have documented why. If the reason is "the click did not work otherwise", you have found an overlay bug, not a Playwright limitation.

Primary references