automationflakywaitsseleniumplaywrightqa

Waits in UI Tests: Why Tests Flake and How to Wait Right

Ask any automation engineer why a test is “green one run, red the next” and the answer is usually the same: waits. The UI is asynchronous — a request goes to the server, a spinner spins, an element renders, an animation finishes. The test, meanwhile, runs instantly and reaches for an element that isn’t there yet. Let’s break down how to wait right and stop chasing flakiness.

sleep(5) — the main villain

The most common “fix” for flakiness is to drop in a pause: time.sleep(5). It loses on two counts at once:

  • Slow. If the element is ready in 0.2s, you still wait 5. Multiply by hundreds of tests and your run bloats by tens of minutes.
  • Still flakes. Today the server responded in 3s; tomorrow, under load, in 6 — and sleep(5) wasn’t enough. You’re just guessing a number: too small and it fails, too big and it drags. You can’t guess right.

Hardcoding time treats the symptom on one machine on one day. The right answer is to wait for an event/state, not for time.

Implicit wait — treacherous

Implicit wait is a global setting: “wait up to N seconds for an element to appear before every lookup.” Sounds convenient, but it bites in production:

  • Masks bugs. The element is missing due to a real defect, but the test silently waits 10 seconds before failing — diagnosis slows down, the cause blurs.
  • Conflicts with explicit wait. Mixing implicit and explicit produces unpredictable combined timeouts (officially discouraged in Selenium).
  • Only about “presence in the DOM.” It waits for the element to be found, not for it to be visible and clickable. Found ≠ ready.

Practice: set implicit to 0 and wait for everything explicitly.

Explicit wait — wait on a condition

Explicit wait is “wait for a specific condition until a timeout.” It’s the right default for Selenium-style frameworks:

WebDriverWait(driver, 10).until(
    EC.element_to_be_clickable((By.ID, "submit"))
)

The key: you wait for a condition, not for time — element visible, clickable, text appeared, spinner gone, row count changed. The moment the condition holds, you proceed immediately (fast); the timeout guards against hangs (stable).

Auto-wait — what Playwright and Cypress change

Modern frameworks (Playwright, Cypress) make waiting built in: before every action the framework itself checks actionability — the element is attached to the DOM, visible, stable (not moving), enabled, not obscured. Plus retrying assertions:

await page.getByRole('button', { name: 'Submit' }).click(); // waits for readiness itself
await expect(page.getByText('Done')).toBeVisible();          // retries until timeout

This removes 90% of manual waits: you write “click” and “assert,” and waiting is the engine’s job. Hence less flakiness out of the box.

Wait on STATE, not a timer

The core mindset shift — anchor to observable state:

  • Network. Wait for the API response, not “about 2 seconds” (waitForResponse, request interception).
  • Spinner/loader. Wait until the loading indicator disappears, not a fixed time.
  • Navigation. URL changed, page loaded, heading appeared.
  • Three different element states. “In the DOM” ≠ “visible” ≠ “clickable.” Click on the last one.
  • Data. The table finished rendering (rows appeared), not “let’s wait, it usually makes it.”

Anti-patterns

  • sleep “just in case” after every action.
  • Waiting a fixed animation duration (sleep(0.3) for a CSS transition).
  • Polling without a timeout — the test can hang forever.
  • Overly large global timeouts — they mask real app slowness.
  • Waiting for “presence” when you need “clickability.”
  • Retrying the whole test instead of fixing the specific wait (masks flakiness).

How to rewrite a flaky test

  1. Find the sleep and ask: what exactly am I waiting for? (element, response, spinner disappearing).
  2. Replace it with an explicit wait for that condition (or rely on auto-wait if the framework supports it).
  3. Click/read on “clickability/visibility,” not on “presence in the DOM.”
  4. For server data, wait for the network response or a final UI state, not for time.
  5. Run the test 20–50 times in a row (including in CI/parallel) — consistently green means you fixed it, not masked it.

In short

  • The #1 cause of flakiness is waits; sleep(N) is both slow and unreliable.
  • Implicit wait is treacherous: it masks bugs and conflicts with explicit — keep it at 0.
  • Explicit wait means waiting on a CONDITION until a timeout: fast when ready, stable when not.
  • Auto-wait (Playwright/Cypress) removes most manual waits.
  • Anchor to state: network response, spinner gone, element clickable — not a timer.
  • Verify the fix with 20–50 runs, not “seems like it doesn’t fail.”