DEV Community

Cover image for waitForResponse() timing: the one-line fix with a non-obvious mental model
Darya Belaya
Darya Belaya

Posted on

waitForResponse() timing: the one-line fix with a non-obvious mental model

The test hung for 30 seconds. The response had already fired. One moved line fixed it.

The test hung for 30 seconds, then timed out.

The browser had received the response. The page had loaded. The data was there.

The test was still waiting.


The wizard

I was writing a helper to walk through a 4-step booking wizard. After clicking "Next" on step 1, the page does a full navigation — window.location.href to step 2. Step 2 immediately loads doctor data from the API.

The helper looked like this:

await Promise.all([page.waitForURL(/step=2/), step1Next.click()]);
await page.waitForResponse(r => r.url().includes('/doctors'));
Enter fullscreen mode Exit fullscreen mode

Standard pattern: wait for navigation, then wait for the data request.

Timeout. Every time.


What I checked first

The URL pattern. Maybe /doctors wasn't matching.

Opened the network tab. The request was there: GET /api/v1/doctors, 200, 47ms. Correct URL, correct response.

The page looked fine. The data was rendered. The test said it was waiting for a response that had already happened.

Added waitForLoadState. Still hung.

Added an explicit waitForSelector for an element that was clearly on the page. That passed. Then waitForResponse hung again.

The response existed. The test couldn't see it.


What was actually happening

page.waitForResponse() is not a query.

It doesn't look at what happened. It registers a listener — from that exact moment forward — and waits for the next matching response.

The sequence in my code:

  1. Promise.all resolves when the URL changes to step=2
  2. By the time the URL changed, step 2 had already loaded
  3. Step 2 had already sent and received /api/v1/doctors
  4. Then waitForResponse registered its listener
  5. Now it's waiting for the next /doctors response
  6. Which never comes

Playwright doesn't buffer missed events. If the response fired before the listener was registered — it's gone.


The fix

await Promise.all([
  page.waitForURL(/step=2/),
  page.waitForResponse(r => r.url().includes('/doctors')),
  step1Next.click(),  // click goes last
]);
Enter fullscreen mode Exit fullscreen mode

Register the listener before triggering the action. When the click fires the navigation, the listener is already active. It catches the response as it happens.

One moved line. The test stopped hanging.


The mental model

waitForResponse reads like a question: "did this response happen?"

It's actually a subscription: "tell me when this response next occurs, starting now."

Those are different things. The first can look backward. The second can only look forward.

This isn't a Playwright quirk — it's how event listeners work. But the API name doesn't make it obvious. waitForResponse sounds like it might check history. It doesn't.

The rule: register before trigger. For any waitFor* method that depends on something triggered by a user action — the listener has to be set up before the action fires. Not after the navigation. Before the click.

This applies to waitForResponse, waitForRequest, waitForEvent — anything that listens for something your action will cause.


Why it matters

The test failure looked like a timing issue. Added waits. Checked selectors. The response was demonstrably there — visible in DevTools, rendered on screen.

The actual problem was in the mental model of what waitForResponse does. The code was structurally wrong, not slow.

Debugging timing when the problem is ordering is a long loop.


The hidden assumption "I assumed waitForResponse would catch a response that had already happened. It only subscribes to responses that haven't happened yet."


Part of the "Hidden Assumptions in Test Automation" series.

Full project (API + UI + E2E + CI + AI endpoint): GitHub

Top comments (0)