Your Playwright test waits for a forecast response but the dashboard fires three similar calls; how do you match the right one?
answer
- One path, several different calls
- Only a predicate sees past the URL
- Method, status, query and body discriminate
- The first passing response wins
- Collect from a listener for a set
basics
~20 sReplace the URL matcher with a predicate. page.waitForResponse accepts a function receiving the Response, so the test can require the method, status, query string or request body, not just a URL all three calls share.
solid answer
~40 s`page.waitForResponse` accepts a glob, a RegExp or a predicate `(response) => boolean`, and only the predicate sees anything beyond the URL. On a weather dashboard that refreshes current conditions, hourly and daily forecasts from the same `/api/forecast` path, match on what actually differs: `new URL(response.url()).searchParams.get('range')`, `response.request().method()`, `response.status()`, or a field of `response.request().postDataJSON()`. Remember the semantics — the wait resolves on the **first** response that satisfies the matcher after the call and then unsubscribes, so with three calls in flight you get whichever lands first and passes. The fix is a tighter predicate, not a second wait. When the test genuinely needs all three exchanges, collect them with a `page.on('response')` listener registered before the action and assert on the collection instead.
code
typescript · 14 linesconst dailyForecast = page.waitForResponse((response) => {
const request = response.request();
return (
request.method() === 'GET' &&
response.url().includes('/api/forecast') &&
new URL(response.url()).searchParams.get('range') === 'daily' &&
response.ok()
);
});
await page.getByRole('tab', { name: 'Daily' }).click();
const response = await dailyForecast;
expect((await response.json()).days).toHaveLength(7);go deeper
Know that page.waitForResponse takes a function as well as a URL, and that the function receives the Response so it can check more than the address.
Explain first-match-wins and what a predicate can reach: status, the request behind the response, its method and its body. Show how you read a query parameter properly.
Demonstrate the diagnosis: three calls share a path, the wait grabbed the wrong one, and the fix is a narrower predicate rather than a retry or a longer timeout. Mention preflights and retries.
Set the house rule for identifying traffic in tests, so predicates stay readable and do not encode incidental facts about a third-party URL shape that will change without notice.
## Why a URL is not an identity A single endpoint rarely means a single call. A weather dashboard can request `/api/forecast` three times on one screen — current conditions, an hourly strip and a seven-day table — differing only by a query parameter, or by method, or not at all if a poller repeats the same call. A glob such as `'**/api/forecast**'` matches every one of them, the wait resolves on whichever arrives first, and the assertion then reads a body produced by a different part of the UI. The test fails with a message that looks like an application bug. ## The matcher forms | Matcher | What it can see | Use when | | --- | --- | --- | | glob string, `'**/api/forecast**'` | the URL only | one endpoint, one call | | RegExp, `/\/api\/forecast\/\d+/` | the URL only | ids or variable path segments | | predicate, `(response) => boolean` | the `Response` and `response.request()` | several similar calls | Both URL forms are matched against the full URL, so they can discriminate on a query string if it is stable — but they cannot see a method, a status or a body. ## Writing a predicate that identifies exactly one call Pick the smallest set of conditions that is true of your call and false of its neighbours: - `new URL(response.url()).searchParams.get('range') === 'daily'` — the robust way to read a query parameter, instead of substring-matching a URL whose parameter order can change. - `response.request().method() === 'POST'` — separates a save from a refresh on the same path. - `response.ok()` or `response.status() === 200` — skips a `401` the app retries after refreshing its token. - `response.request().postDataJSON()?.stationId === 'LHR'` — keys on something in the outgoing body when the URL is identical for every call. - `response.request().resourceType() === 'fetch'` — keeps documents, images and preflights out of the match. Keep predicates cheap and synchronous in spirit: they run against every response the page receives until one passes. ## First match wins 1. The wait resolves on the first response satisfying the matcher **after** the call, then stops listening. 2. A second identical call is not visible to that promise. If the test needs both, create both waits before the action, or collect from a listener. 3. When the app retries, a predicate requiring `response.ok()` steps over the failed attempt and lands on the successful retry — which is usually what you meant, and worth stating explicitly rather than relying on luck. ## Collecting instead of waiting When the interesting assertion is about the *set* of calls — "the dashboard requested each range exactly once" — a wait is the wrong shape. Register a listener before the action and assert on what it gathered: ```ts const ranges: (string | null)[] = []; page.on('response', (response) => { if (response.url().includes('/api/forecast')) { ranges.push(new URL(response.url()).searchParams.get('range')); } }); const daily = page.waitForResponse((r) => r.url().includes('range=daily')); await page.getByRole('button', { name: 'Refresh all' }).click(); await daily; expect(ranges.sort()).toEqual(['current', 'daily', 'hourly']); ``` Note that the collection is only asserted after an exchange the test explicitly awaited, so the array is not read before the traffic exists. ## Debugging a matcher that never fires - Log every response URL from a temporary listener. If the URL is absent, the app never called; if it is present, the matcher is wrong. - Check the origin. A dev server proxying `/api` to another port produces URLs your glob may not anticipate. - Check the resource type. A CORS preflight `OPTIONS` shares the URL and will satisfy a URL-only matcher before the real call arrives. - Remember the timeout message reads the same for a wrong matcher and a missing call, so narrow the predicate one condition at a time rather than rewriting it wholesale.
- How would you wait for the second of two identical calls to the same endpoint?Create both waits before the action; the first promise takes the first match and the second takes the next one. If they are truly indistinguishable and order matters, a `page.on('response')` collector plus an assertion on the collected array is clearer than stacked waits.
- What breaks when a predicate matches a CORS preflight instead of the real call?The wait resolves with the `OPTIONS` response, which has no body to assert on, so the test fails on parsing rather than on the data. Require the method or the resource type in the predicate to exclude preflights.
- Why prefer searchParams over a substring check on the URL?Parameter order, encoding and added tracking parameters all change the raw string without changing meaning. Parsing with `new URL(...)` and reading `searchParams` asserts the intent, so the test survives harmless URL churn.
saying these in an interview costs you the question
- Assuming one endpoint means one request per screen
- Waiting twice for the same promise to get a second call
- Matching a whole URL string including query order
- Ignoring that a preflight OPTIONS shares the URL
- Believing the wait returns the last matching response