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'));
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:
-
Promise.allresolves when the URL changes tostep=2 - By the time the URL changed, step 2 had already loaded
- Step 2 had already sent and received
/api/v1/doctors - Then
waitForResponseregistered its listener - Now it's waiting for the next
/doctorsresponse - 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
]);
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)