Stop using waitFor* as a crutch in Playwright E2E

If your Playwright tests are full of waitForTimeout() or “wait a bit then click”, you are buying flakiness and slow runs. Playwright already has smarter waiting built in. Your job is to wait for a condition, not wait for time.

What’s wrong with waitForTimeout() (hard waits)

page.waitForTimeout(2000) just sleeps. It does not care whether the app is ready at 50 ms or still loading at 10 s.

That creates 3 predictable problems:

  1. Flaky tests
    • If CI is slower than your laptop, 2 seconds is suddenly not enough, tests fail randomly. Hard waits are explicitly called out as fragile by testing vendors and Playwright practitioners. Checkly+1
  2. Slow test suites
    • Even when the page is ready immediately, you still waste the full sleep every time.
  3. Hides real bugs
    • You stop asserting what should happen (state), and you start assuming it will happen “eventually”.

Why waitForSelector() is usually the wrong “explicit wait”

A lot of people replace waitForTimeout() with waitForSelector() and call it a day. Better, but still often unnecessary.

Playwright’s Locators already auto-wait and retry. Actions like locator.click() wait for “actionability” (visible, enabled, stable, etc.) before interacting. Playwright+1
Playwright’s own guidance is basically: use locators + web-first assertions, not manual waits. Playwright+1

There’s even a long-standing Playwright discussion where the maintainers point out there’s “no reason” to do waitForSelector if you’re going to use a locator and act on it anyway. GitHub

What to do instead (the reliable pattern)

1) Use Locators, then act

Locators are designed to be the “wait layer”. Playwright+1

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

2) Assert the UI state (web-first assertions)

Playwright assertions like toBeVisible, toHaveText, etc. auto-retry until they pass or time out, so they work as “smart waits”. Playwright+1

await expect(page.getByText('Saved')).toBeVisible();

3) Wait on the thing that actually matters (network or navigation), only when needed

If a click triggers a request or navigation, synchronize on that event, not time.

await Promise.all([
  page.waitForURL('**/success'),
  page.getByRole('button', { name: 'Pay' }).click(),
]);

When a waitFor* is acceptable

Rare cases exist, but be honest, 90% of usage is habit.

Reasonable uses:

  • Waiting for a very specific non-UI event you can’t observe via assertions.
  • Debugging locally (temporarily), not in committed test code.
  • A truly unavoidable third-party animation or transition, and even then prefer waiting for the end state (e.g., element is stable/visible), not a fixed delay.

The blunt rule

If you can rewrite your waitForTimeout() as an assertion about the UI, do it.
If you can rewrite your waitForSelector() as a locator action or a web-first assertion, do it.

That’s how you get tests that are faster, less flaky, and more meaningful.