skip to content

Why must page.waitForResponse be created before the click that triggers the request in Playwright?

level: middleimportance: must knowfreq 61%

answer

  1. The wait is a subscription
  2. No replay buffer of past traffic
  3. Fast responses win the race
  4. Assign the promise, then act
  5. A bigger timeout never fixes it

basics

~10 s

Because the wait subscribes to the page's network events at the moment it is called. A response that arrived before that call is never seen, so the promise sits unresolved until it times out.

solid answer

~40 s

`page.waitForRequest` and `page.waitForResponse` are event subscriptions, not history queries. Calling one attaches a listener to the `Page` and resolves on the first matching event **after** that point; Playwright keeps no replay buffer of exchanges that already finished. So if a test awaits `page.getByRole('button').click()` and only then calls `await page.waitForResponse('**/api/forecast**')`, a fast forecast call can complete during the click, and the wait burns its full 30-second timeout before failing. Worse, it passes whenever the API happens to be slow, so the bug is environment-dependent rather than reproducible. The fix is ordering, not a bigger timeout: create the promise, run the action, await the promise. `Promise.all([page.waitForResponse(...), button.click()])` is the older spelling of the same idea. The rule applies equally to `page.on('request')` listeners.

code

typescript · 13 lines
typescript
test('refresh calls the forecast API', async ({ page }) => {
  await page.goto('/dashboard');

  // Subscribe first: the wait only sees traffic that arrives after this line.
  const responsePromise = page.waitForResponse(
    (response) => response.url().includes('/api/forecast') && response.status() === 200,
  );

  await page.getByRole('button', { name: 'Refresh' }).click();

  const response = await responsePromise;
  expect(await response.json()).toHaveProperty('daily');
});

go deeper

for a junior

Remember the ordering rule, not just the API: build the wait, then click, then await. If a test times out waiting for a call you can see happening, suspect the order first.

for a middle

Explain the mechanism. The wait registers a listener when called and resolves on the next matching event, so anything already dispatched is unreachable and only a timeout can follow.

for a senior

Show how you diagnose it: the trace shows the request before the wait started, the timeout message looks the same as a wrong matcher, and a late wait that matches a later poll is the silent version of the bug.

for a principal

Make the correct shape the default in the team's helpers and review checklist. This defect is invisible on a fast local API and expensive on CI, so it is worth encoding rather than teaching case by case.

## A subscription, not a query `page.waitForResponse` and `page.waitForRequest` do not search history. Calling one attaches a listener to the page's network events and returns a promise that settles on the first event matching your matcher **after** the call. Playwright keeps no replay buffer of finished exchanges, so an exchange that completed a millisecond earlier simply is not there to be found. The same is true of `page.on('request')` and friends: a listener records what happens from the moment it is registered. That single fact explains a whole family of "the request definitely happened, but the test timed out" failures. ## Why the bug is intermittent | Ordering in the test | Fast API | Slow API | | --- | --- | --- | | click awaited, then wait created | times out | passes | | wait created, then click awaited | passes | passes | The wrong order only fails when the response wins the race, which is why it survives review and then fails on one machine and not another. A mocked or locally-served forecast API answers in single-digit milliseconds and loses almost every race; a real staging API answers in 300 ms and hides the defect completely. ## The two orderings that work 1. **Assign, act, await** — the current idiom. `const p = page.waitForResponse(matcher);` then `await action;` then `const response = await p;`. It reads in the order the events occur and keeps the matcher next to the action that provokes it. 2. **`Promise.all`** — the older idiom, still valid. In `Promise.all([page.waitForResponse(matcher), button.click()])`, the array elements are evaluated left to right, so the wait is created and subscribed before the click promise has had a chance to settle. Both work because the subscription happens synchronously, before control is yielded to the action. ## The same rule for listeners - Register `page.on('request')` **before** `page.goto` if you want the navigation's own requests; a listener added afterwards misses them. - A listener registered in a `beforeEach` on the built-in `page` fixture covers the whole test, which is the usual home for a collector. - `page.once` handles a single event; `page.off(handler)` detaches one you no longer want firing. - A collector array is only meaningful once the traffic has happened. Reading it on the line after the click is the same race in a different costume — await something that proves the exchange occurred first. ## Diagnosing it in a failing run - The failure message names the matcher and the timeout, so it looks identical whether the URL was wrong or the ordering was wrong. Check the URL first, then the ordering. - The recorded trace's network panel shows the request happened, with a timestamp earlier than the wait — that gap is the whole diagnosis. - Raising the timeout never fixes this class of failure. If the response were merely slow, the wait would already have caught it; a wait that missed its event will wait forever. - A quick sanity check: log every response URL from a temporary `page.on('response')` listener registered at the top of the test. If the URL is in the log but the wait timed out, the ordering is the cause. ## What it costs when it goes unnoticed A wait that is registered too late does not always fail. If it accidentally matches a *later* call — a polling refresh on the dashboard, say — it resolves with the wrong payload and the assertion checks data the action under test never produced. That is worse than a timeout, because the test still reports green while verifying nothing about the click.

  • Does Promise.all with the click listed first still work?
    Yes. Array elements are evaluated left to right and both calls are made synchronously, so `page.waitForResponse` subscribes before the click promise can settle. It reads confusingly though, which is why the assign-act-await form is now the documented idiom.
  • What is the danger of a wait that registers late but still resolves?
    It matched a different exchange — often a later poll or retry of the same endpoint. The test then asserts on a payload the action under test never produced, so it stays green while verifying nothing. Narrow the matcher and fix the ordering.

saying these in an interview costs you the question

  • Raising the timeout to fix a wait that missed its event
  • Saying Playwright buffers past requests for later waits
  • Reading a collector array immediately after the click
  • Registering page.on listeners after the navigation they should record
  • Calling the failure a slow API rather than an ordering bug