Test Automation

Master Playwright Waits: Ditch wait_for_response() [2025]

Tired of flaky Playwright tests? Discover why `wait_for_response()` is an anti-pattern in 2025 and master modern, reliable waiting strategies for robust automation.

D

Daniel Carter

Principal Test Architect specializing in resilient, scalable automation frameworks for modern web applications.

7 min read11 views

We’ve all been there. Your end-to-end tests pass flawlessly on your machine, but the moment they hit the CI/CD pipeline, they start flickering. A sea of red failures, all pointing to a dreaded TimeoutError. You dig in, add a few more seconds to the timeout, and maybe, just maybe, it goes green. But you know, deep down, you haven’t solved the problem. You’ve just kicked the can down the road.

For years, a common culprit in these flaky Playwright tests has been a seemingly helpful function: page.waitForResponse(). It feels intuitive, right? You click a button, your app makes an API call, so you should just wait for that network response to complete before moving on. Simple. Logical. And in 2025, fundamentally wrong.

Let's explore why relying on waitForResponse() is a fragile anti-pattern and how to embrace modern, resilient waiting strategies that will make your tests faster, more reliable, and infinitely more maintainable.

The Alluring Trap of wait_for_response()

When you're starting with test automation, waiting for network calls seems like a direct and explicit way to handle asynchronous operations. The logic goes something like this:

  1. User performs an action (e.g., clicks 'Login').
  2. A network request to /api/auth is sent.
  3. The UI is in a loading state.
  4. The response arrives, the UI updates with a 'Welcome!' message.

A test author might translate this into code like this:

// The "intuitive" but flawed approach
await page.getByRole('button', { name: 'Login' }).click();

// Wait for the authentication API call to complete
await page.waitForResponse('**/api/auth');

// Now, check if the welcome message is visible
await expect(page.getByText('Welcome, Sarah!')).toBeVisible();

On the surface, this looks solid. It explicitly waits for the background work to finish before asserting the result. It’s no wonder so many tutorials and older codebases are littered with this pattern. But this apparent safety is an illusion, masking deep-seated issues that lead directly to flaky tests.

Why Your Tests Are Flaking: The Anti-Pattern in Detail

The problem with waitForResponse() isn't that it doesn't work; it's that it creates tests that are brittle and don't reflect user interaction. Here’s the breakdown of why it’s time to ditch it.

The Dreaded Race Condition

This is the number one cause of flakiness. Look at the code snippet above. There are two distinct await statements. What happens if the network is incredibly fast?

The click() action could trigger the request, and the server could respond before Playwright even starts listening for it in the next line, page.waitForResponse().
Advertisement

When this happens, your test will hang, waiting for a response that has already come and gone, eventually failing with a timeout. This explains why tests might pass on a slower local machine but fail in a high-performance CI environment. You're literally in a race against the network, and that's a race you can't win consistently.

You're Not Testing Like a User

Think about how a real person uses your application. Do they open the browser's developer tools and watch the Network tab to decide when they can proceed? Of course not.

A user waits for a visual cue. They wait for a loading spinner to disappear. They wait for a success message to pop up. They wait for their name to appear in the header. Their “wait condition” is the state of the UI, not the state of the network. Your tests should model this behavior. By waiting on an invisible, technical implementation detail, you're creating a test that doesn't actually verify the user experience.

Coupling to Implementation, Not Behavior

Let's say your development team decides to refactor the authentication endpoint. The URL changes from /api/auth to /api/v2/session. The front-end is updated, and from a user's perspective, the login flow is completely unchanged. The 'Welcome, Sarah!' message still appears perfectly.

However, your test will now fail. Why? Because it was hardcoded to wait for **/api/auth. Your test is now broken, not because the user-facing feature is broken, but because an internal implementation detail changed. This creates a maintenance nightmare, forcing you to update tests for backend changes that have no impact on the user journey. Good tests verify behavior, not implementation.

The Modern Playwright Paradigm: Asserting the Outcome

So, if we're not supposed to wait for responses, what's the alternative? The answer is both simpler and more powerful: wait for the user-visible result.

This is where the magic of Playwright's Web-First assertions comes in. Functions like expect(locator).toBeVisible() are not just assertions; they are auto-retrying wait-and-assert commands. When you write this, Playwright will:

  1. Check if the element is visible.
  2. If not, it waits a short period and tries again.
  3. It continues this cycle of waiting and re-checking until the element appears or a global timeout is reached.

This single line replaces both the explicit wait and the assertion, completely eliminating the race condition and the other problems we discussed.

Practical, Rock-Solid Examples

Let's refactor our login test to the modern approach.

// The modern, resilient, and correct approach
await page.getByRole('button', { name: 'Login' }).click();

// Assert the FINAL, user-visible outcome. Playwright handles the waiting automatically!
await expect(page.getByText('Welcome, Sarah!')).toBeVisible();

// You can even assert on the URL if a redirect occurs
await expect(page).toHaveURL(/.*/dashboard/);

This code is cleaner, more readable, and fundamentally more robust. It doesn't care what API endpoints were called or how long they took. It only cares about one thing: did the 'Welcome, Sarah!' message appear on the screen for the user? This is the essence of effective behavioral testing.

The Exception That Proves the Rule

Is there ever a time to use waitForResponse()? Yes, but it's rare. You should only use it when you are specifically testing the network call itself, and there is no corresponding, reliable UI change to assert on.

  • Testing Analytics: Verifying that a specific analytics event was fired.
  • File Downloads: Triggering a download and asserting the response headers.
  • API Status Checks: Ensuring a specific action results in a 401 Unauthorized or other status code that might not have a unique UI state.

Even in these cases, you must use it correctly to avoid the race condition. The key is to start waiting before you perform the action, using Promise.all() to run both simultaneously.

// The SAFE way to wait for a response when absolutely necessary

// Start waiting for the response BEFORE the action that triggers it
const responsePromise = page.waitForResponse('**/api/analytics/track');

// Perform the action
await page.getByRole('button', { name: 'Add to Cart' }).click();

// Now, await the promise you created earlier
const response = await responsePromise;

// You can now safely assert on the response object
expect(response.status()).toBe(200);
expect(await response.json()).toContainEqual({ event: 'item_added' });

This pattern ensures your listener is active before the event can possibly occur, making it safe from races.

At a Glance: Old vs. New Waiting Strategies

Metric waitForResponse() (The Old Way) expect(locator)... (The Modern Way)
Reliability Low 📉 (Prone to race conditions) High ✅ (Auto-retrying, eliminates races)
Maintainability Low 🛠️ (Tied to implementation details) High ✨ (Tied to user behavior)
User-Centric No 🤖 (Tests technical details) Yes 🧑‍💻 (Tests what the user sees)

Your New Mantra for 2025: Wait for Results

As you write and refactor your Playwright tests in 2025, adopt this simple mantra: Stop waiting for networks; start asserting on outcomes.

Trust in Playwright's auto-waiting assertions. Let them handle the complexities of timing. By focusing your tests on the final, user-visible state of the application, you're not just fixing flakiness. You're building a test suite that is more meaningful, resilient, and far easier to maintain in the long run. Go forth and delete every last unnecessary waitForResponse() from your codebase. Your future self will thank you.

Tags

You May Also Like