TechByteByByte

Part 12: Waiting and Synchronization

Understand auto-waiting and synchronization so tests remain stable without arbitrary delays.

The test and webpage do not always move at the same speed. Synchronization means continuing when a required condition is ready.

Wait for toast to pop up instead of guessing that it always takes exactly 30 seconds.

start work → watch condition → condition becomes true → continue

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

This part has been referenced constantly throughout the series so far — Part 7’s brief introduction to auto-waiting, Part 9’s explanation of why assertions retry, Part 10’s honest caveat about goto()’s default wait behavior. Now it’s time to go properly deep: why waiting is a genuinely hard problem in browser automation, exactly what Playwright does about it internally, and — critically — why arbitrary waits are one of the most damaging habits a beginner can pick up.


Why Waiting Is Fundamentally Necessary

Go back to Part 0.3’s core insight: a browser and a server are two separate things, communicating over a network, and that communication takes real, variable time. Now add something Part 1.3 established: the DOM itself keeps changing after a page loads, as JavaScript runs. Put these together, and you get a race condition — a situation where the outcome of your code depends on the unpredictable timing of two things happening independently, and if your test’s next line runs before the application’s response finishes, the test checks a state that doesn’t exist yet.

Concretely: click SauceDemo’s “Add to cart” button, and here’s roughly what actually has to happen before the cart badge correctly shows “1” — the click event fires, JavaScript’s click handler runs, it updates some internal state, it re-renders the badge element in the DOM with the new count.

This all happens fast, genuinely — often just milliseconds — but it is not instant, and “not instant” is exactly the gap where race conditions live.

A test that checks the badge’s text on the very next line, with zero waiting whatsoever, is gambling on that gap being small enough to not matter, every single time, on every machine, under every load condition — which is a bet that eventually loses, usually at the worst possible moment (a slower CI server, a particularly busy day, a coworker’s older laptop).


Actionability — What Playwright Is Actually Checking

Part 7 briefly listed the conditions Playwright checks before performing an action. Let’s go through each one properly, because understanding why each condition exists (not just that it exists) is what lets you correctly diagnose a confusing timeout later.

  • Attached — the element genuinely exists in the DOM. Obvious, but worth stating: you can’t click something that isn’t there yet.
  • Visible — the element has a non-zero size and isn’t hidden via CSS (display: none, visibility: hidden, or opacity: 0, among other possibilities). A real user can’t click something they can’t see; Playwright refuses to pretend otherwise.
  • Stable — the element isn’t currently in the middle of an animation or transition. Imagine a modal sliding into place — clicking a button on it mid-slide, at a specific pixel coordinate, could easily miss, because the button’s actual on-screen position is still changing. Playwright waits for the element’s position to stop changing across at least two consecutive animation frames before proceeding.
  • Receives events — the element isn’t covered by another element sitting on top of it (a loading spinner overlay, for instance). A real user’s click on a covered element would actually hit whatever’s on top, not the element underneath — Playwright’s check reflects this exact real-world constraint.
  • Enabled — the element isn’t disabled (a common state for a submit button while a form is still being validated, or while a previous request is in flight).

For a fill() action specifically, there’s one more check: the target must be an editable element — a real, working form field a user could type into, not, say, a plain <div> with no input capability at all.

Every one of these checks exists because it mirrors a genuine constraint a real human user would also face. This is worth internalizing as a guiding principle for the rest of your automation career: Playwright’s actionability checks aren’t arbitrary hoops the framework makes you jump through — they’re a deliberate simulation of what a real user could and couldn’t actually do at that exact moment.


Explicit Waits — When Auto-Waiting Genuinely Isn’t Enough

Auto-waiting handles the overwhelming majority of real waiting needs automatically. But some scenarios genuinely need you to wait for something more specific than “this one element is actionable” — and Playwright gives you precise tools for exactly these cases, rather than forcing you back to guesswork.

// Wait for a specific network request to complete
await page.waitForResponse(
  (response) =>
    response.url().includes("/api/products") && response.status() === 200,
);

// Wait for the URL to change to something matching a pattern
await page.waitForURL(/inventory\.html/);

// Wait for a specific DOM state (rarely needed on top of auto-waiting, but available)
await page.waitForSelector(".inventory_item", { state: "attached" });

waitForResponse deserves particular attention, because it solves a real problem auto-waiting can’t: sometimes you specifically need to know that a particular backend call completed — not just that some element eventually became visible — especially useful when you’re deliberately testing the interaction between frontend and backend together (a direct preview of Part 17 and Part 19).

Assertion-based waiting

— simply using await expect(...).toBeVisible() and letting its built-in retry do the work, as covered fully in Part 9 — is, deliberately, the preferred explicit-waiting mechanism for almost all cases, precisely because it doubles as the actual verification you needed anyway. Reaching for a separate, standalone wait and then a separate assertion is usually redundant — the assertion’s own retry already handles the waiting.


waitForTimeout — Why It’s (Almost) Always a Bad Solution

Playwright provides page.waitForTimeout(milliseconds) — a fixed-duration pause with no relationship to the page’s actual state:

await page.getByRole("button", { name: "Add to cart" }).click();
await page.waitForTimeout(2000); // wait exactly 2 seconds, no matter what
await expect(page.locator(".cart-badge")).toHaveText("1");

Analogy: The Bus Stop vs. The Road Watcher Imagine waiting for a bus to arrive:

  • Hard Wait (waitForTimeout): You walk to the bus stop, set a timer for exactly 3 minutes, close your eyes, and plug your ears. If the bus arrives in 30 seconds, you waste 2.5 minutes sitting idle. If the bus arrives in 3 minutes and 10 seconds, you step onto the road before the bus is actually parked and risk a collision (test failure).
  • Auto-Waiting (expect): You stand at the curb and watch the road continuously. The moment the bus arrives and opens its doors (after 30 seconds or 2 minutes), you step on immediately. If the bus doesn’t arrive within a safety limit (say, 5 minutes), you call a taxi (timeout failure).

📊 Visual Flowchart: Hard Wait vs. Auto-Waiting Polling

Here is how the execution timeline differs when an element loads in 200ms:

graph TD
    subgraph HardWait ["Hard Wait (waitForTimeout)"]
        HW1["Click 'Add to Cart'"] --> HW2["Sleep/Wait exactly 2000ms<br>(wastes 1800ms)"]
        HW2 --> HW3["Verify Cart Badge"]
    end

subgraph AutoWait ["Auto-Waiting (expect)"]
        AW1["Click 'Add to Cart'"] --> AW2{"Cart Badge loaded?<br>(Poll every 100ms)"}
        AW2 -->|No| AW3{"Timeout elapsed?"}
        AW3 -->|No| AW2
        AW3 -->|Yes| AW_Fail["Fail Test"]
        AW2 -->|Yes (after 200ms)| AW_Pass["Pass immediately<br>(Continue test)"]
    end

It’s worth being completely honest about why this is a genuine anti-pattern, not just a stylistic preference. This wait is a guess. If the cart badge actually updates in 300ms, this test just wasted 1.7 unnecessary seconds, every single run, adding up meaningfully across a suite with hundreds of tests.

If, on a slower CI server or under unusual load, the update genuinely takes 2.5 seconds instead, this exact same test fails — not because the application is broken, but because the guess was wrong this specific time.

This is precisely the mechanism behind a huge share of real-world flaky tests: a fixed wait that happened to be “enough” on the developer’s fast laptop, that then intermittently fails on a slower or more loaded CI machine.

Compare this directly to await expect(page.locator('.cart-badge')).toHaveText('1') on its own, with no waitForTimeout at all — it polls automatically, passes the instant the badge actually updates (however long that genuinely takes, whether 100ms or 1.5 seconds), and never wastes time waiting longer than necessary. This isn’t a marginal improvement — it’s a structurally different, correct approach to the exact same problem waitForTimeout is trying, and failing, to solve.

waitForTimeout isn’t entirely without legitimate use — genuinely rare situations exist (debugging, deliberately simulating a slow network condition in a specific, controlled test) where a fixed pause is actually appropriate.

But as a general habit for “make my flaky test pass,” it should be treated as close to off-limits, and its presence in a test suite is one of the clearest, most reliable signs — worth learning to spot immediately when reviewing a teammate’s pull request — that whoever wrote it reached for the easy fix instead of understanding what their test was actually waiting for.


Network-Idle Pitfalls

You may have encountered page.waitForLoadState('networkidle') — waiting until there have been no network requests for a short period, on the theory that this means the page has “fully settled.” It’s worth an explicit, honest warning here, because this option looks reassuring and is genuinely tempting to reach for by default, but has a real, well-known problem: many modern web applications never actually go fully network-idle for meaningful stretches of time — background polling, analytics pings, chat widgets, and similar ongoing traffic can keep the network active indefinitely, meaning networkidle either times out unnecessarily or simply isn’t a reliable signal of “the specific thing I care about has finished loading” at all.

Playwright’s own official guidance has moved away from recommending networkidle as a general-purpose waiting strategy for exactly this reason. The dramatically more reliable approach, in nearly every real case, is to wait for the specific thing your test actually needs — a particular element becoming visible via a web-first assertion, or a specific API response via waitForResponse — rather than a vague, global proxy like “the network seems quiet.”


How It Works in a Real Test Run

Synchronization means waiting for a meaningful condition, not waiting for time to pass. Playwright auto-waits before actions and retries web-first assertions, while application-specific events may require waiting for a response, URL, download, popup, or visible status.

A reliable flow is trigger operation and register its event wait together → await the event → assert the resulting UI. A fixed delay guesses how long the system needs and therefore fails on faster and slower environments.

Interview Questions

Q: What is a race condition, and why does it apply directly to browser test automation?

Ans: A race condition is a situation where the correctness of a test’s outcome depends on unpredictable timing between two independent things — here, the test’s next line of code, and the application’s asynchronous response to a previous action. If the test proceeds before the application has actually finished updating, it ends up checking a state that hasn’t happened yet, which can cause an inconsistent, timing-dependent failure even though the application itself works correctly.

Q: Name at least three of Playwright’s actionability checks, and explain why each one reflects a real constraint a human user would also face.

Ans: Visibility — a real user can’t click something they can’t see. Stability — a real user can’t reliably click an element that’s still moving mid-animation, since its actual on-screen position keeps changing. Receiving events — a real user’s click on an element covered by something else (like a loading overlay) would actually land on whatever’s on top, not the element underneath. Each check exists to simulate a genuine limitation a real user experiences, rather than being an arbitrary technical hurdle.

Q: Why is page.waitForTimeout(2000) generally considered an anti-pattern, even though it often makes a flaky test pass?

Ans: It’s a fixed guess with no actual relationship to the page’s real state — if the true wait needed is shorter, it wastes time on every run; if it’s occasionally longer (due to slower CI, higher load, or other variability), the test fails intermittently despite the application working correctly. This is a common, direct cause of flaky tests, since the fixed duration was often only ever “enough” under the specific conditions it happened to be tested under, not reliably enough under all conditions.

Q: Why is await expect(locator).toBeVisible() generally preferable to waitForTimeout followed by a non-retrying check, for the exact same scenario?

Ans: The web-first assertion polls automatically and passes the instant the condition becomes true, however long that genuinely takes — it doesn’t waste time beyond what’s actually necessary, and it doesn’t fail just because the true wait time happened to exceed a fixed guess on a particular run. It also directly serves as the verification itself, rather than being a separate, disconnected pause followed by a second, non-retrying check.

Q: Why has networkidle become a less recommended waiting strategy, despite sounding like a comprehensive, safe choice?

Ans: Many modern applications maintain ongoing background network activity — polling, analytics, chat widgets — that can prevent the network from ever genuinely going idle for a meaningful stretch, making networkidle either time out unnecessarily or fail to reliably signal that the specific content a test actually cares about has finished loading. Waiting for the specific element or response the test genuinely depends on is a far more precise and reliable strategy than relying on a vague, page-wide proxy signal like overall network quietness.

Q: A teammate’s test intermittently fails in CI but always passes locally. They’ve added a waitForTimeout(3000) before the failing assertion, and it now passes reliably in CI too. Is this actually a good fix? What would you investigate instead?

Ans: It’s very likely a symptomatic fix rather than an actual one — it’s plausible CI is simply slower than their local machine, and the fixed wait happens to now be “enough” under CI’s specific current conditions, but that’s fragile and could start failing again the moment CI is under slightly heavier load. I’d investigate what the test is actually waiting for and replace the fixed timeout with a targeted wait for that specific condition — a web-first assertion on the actual element or state the test depends on, or waitForResponse if it’s waiting on a specific backend call — so the test waits exactly as long as genuinely necessary, and no longer, regardless of the underlying environment’s speed.


Exercises — Part 12

Understand: Without looking back, explain in your own words what a race condition is, using the “Add to cart” badge-update example, and why a zero-wait, immediate check after the click is gambling rather than testing reliably.

Simple Practice: Take a test you’ve already written earlier in this series that includes any waitForTimeout (or write a new small one that deliberately uses it), and rewrite it to use an appropriate web-first assertion instead, removing the fixed wait entirely.

Real-World Scenario: Imagine you’re reviewing a teammate’s pull request and find await page.waitForTimeout(5000); sitting right before an assertion, with no comment explaining why. Write out, as if you were actually leaving a code review comment, what you’d say — both the specific concern and a concrete suggested alternative.

Challenge: Research Playwright’s waitForResponse documentation and write a short test scenario (in your own words, pseudocode is fine) for a case where waiting for a specific API response is genuinely necessary — something a web-first UI assertion alone couldn’t reliably confirm on its own.


Next: Part 13 — Frames, Dialogs, Popups and Tabs

— iframes, frame locators, and handling native browser alerts, confirms, and prompts.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed