Playwright waitForResponse pauses your test until a specific network response comes back, then hands you that response so you can check its status and data. It is the tool for "click Save, then wait for the API to answer", and it is one of the few waits you should reach for on purpose. For how it fits with auto-waiting, assertions and the other waits, see the Playwright wait guide.
This guide covers the signature, the one pattern you must get right, matching by URL, glob, RegExp or predicate, reading json(), waitForRequest, the requestfinished event, how to wait for network requests to finish without networkidle, mocks, and timeouts. Every example is JavaScript, hits a public practice site, and passed on Playwright 1.62.1.
Quick answer: const responsePromise = page.waitForResponse('**/api/v1/fruits'); then do the click or goto, then const response = await responsePromise;. Start waiting before the action, never after.
What Playwright waitForResponse Does
page.waitForResponse(urlOrPredicate, options) returns a promise that resolves with the first Response that matches. The first argument can be:
- A string: a full URL or a glob such as
'**/api/v1/fruits'. It must match the whole URL. A partial string like'api/v1/fruits'does not match, so the wait runs until it times out. - A RegExp:
/\/users\/\d+$/. - A predicate: a function that receives the response and returns
truefor the one you want. It can be async.
The options object takes timeout (milliseconds, 0 disables it) and signal (an AbortSignal). That is the whole API. The hard part is not the options; it is when you call it.
The Promise-Before-Action Pattern (and Why)
The API mocking demo loads a list of fruits from /api/v1/fruits as soon as the page opens. Here is the correct way to wait for that call:
const { test, expect } = require('@playwright/test'); test('wait for the fruits API while the page loads', async ({ page }) => { // 1. Start waiting BEFORE the action. No await here. const responsePromise = page.waitForResponse('**/api/v1/fruits'); // 2. Trigger the request. await page.goto('https://demo.playwright.dev/api-mocking/'); // 3. Now await the response. const response = await responsePromise; expect(response.status()).toBe(200); const fruits = await response.json(); expect(fruits.length).toBeGreaterThan(0); // The UI shows what the API returned await expect(page.getByRole('listitem').first()).toHaveText(fruits[0].name); });
Three steps, always in this order:
- Create the promise without
await. Playwright starts listening for responses right now. - Do the action that sends the request: a click, a form submit, a
goto. - Await the promise. If the response already arrived during step 2, it resolves immediately.
Why not just await page.waitForResponse(...) after the click? Because waitForResponse only sees responses that arrive after you call it. If the API answers while the click is still finishing, the response is gone and your wait sits there until it times out. This test proves it:
const { test, expect } = require('@playwright/test'); test('starting the wait too late misses the response', async ({ page }) => { await page.goto('https://demo.playwright.dev/api-mocking/'); await expect(page.getByRole('listitem').first()).toBeVisible(); // The fruits request already happened during goto, so this never resolves await expect( page.waitForResponse('**/api/v1/fruits', { timeout: 3000 }) ).rejects.toThrow('Timeout 3000ms exceeded'); });
The nasty part is that the late version sometimes passes. Start the wait right after goto (without waiting for the list first) and it can pass, because the fetch often finishes a few milliseconds after the page's load event. On a faster machine or a warm cache it would fail. That is a textbook flaky test: the code is wrong, but the timing hides it most of the time.
Flaky test that "only fails in CI"? Usually it is a wait in the wrong place: a waitForResponse started too late, a fixed waitForTimeout, or networkidle on a page that never goes idle. See the waits that cause flaky Playwright tests, and how to fix them →
Match by URL, Glob, RegExp or Predicate
A glob is enough when the URL alone identifies the call. When the same URL is used with different methods, or you only care about a successful response, use a predicate. This example waits for a POST to /posts that returns 201 on JSONPlaceholder:
const { test, expect } = require('@playwright/test'); test('wait for a POST that returns 201', async ({ page }) => { await page.goto('https://jsonplaceholder.typicode.com/'); const responsePromise = page.waitForResponse(resp => resp.url().endsWith('/posts') && resp.request().method() === 'POST' && resp.status() === 201 ); // The page sends the request (a button click in a real app) await page.evaluate(() => fetch('/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Playwright', body: 'waits', userId: 1 }), }) ); const response = await responsePromise; const body = await response.json(); expect(body).toMatchObject({ id: 101, title: 'Playwright' }); }); test('match with a regular expression', async ({ page }) => { await page.goto('https://jsonplaceholder.typicode.com/'); const responsePromise = page.waitForResponse(/\/users\/\d+$/); await page.evaluate(() => fetch('/users/3')); const user = await (await responsePromise).json(); expect(user.id).toBe(3); });
A few notes:
resp.request().method()gives you the HTTP method. Without it, aGET /postsfired by the same page could resolve your wait.page.evaluate(() => fetch(...))stands in for "the app sends a request". In your own tests that line is aclick()or a form submit.- In the RegExp, the
$stops/users/3from also matching/users/3/posts. - The predicate runs for every response on the page, so keep it cheap. Don't call
resp.json()inside it unless you really need to match on the body.
| Matcher | Example | Use when |
|---|---|---|
| Glob string | '**/api/v1/fruits' | One endpoint, any host or base path |
| Full URL | 'https://demo.playwright.dev/api-mocking/api/v1/fruits' | You know the exact URL |
| RegExp | /\/users\/\d+$/ | IDs or other variable parts in the path |
| Predicate | r => r.url().endsWith('/posts') && r.request().method() === 'POST' | Method, status or header matters |
Read the Response: status(), ok() and json()
The resolved Response object gives you everything the browser received:
response.status()andresponse.ok()(true for 200–299).await response.json(),await response.text()orawait response.body()for the payload.response.headers()andresponse.url().response.request()for the request that caused it.
One thing that surprises people: a 404 or 500 still resolves the promise. For example, a request to /posts/9999 on JSONPlaceholder resolves waitForResponse with status() === 404 and ok() === false. If a failed call should fail your test, assert on the status, as the fruits example does with expect(response.status()).toBe(200).
A good habit is to check both sides: the API returned the right data, and the UI shows it. The fruits test does exactly that by comparing fruits[0].name with the first list item.
waitForRequest and the requestfinished Event
page.waitForRequest has the same signature but resolves when the browser sends the request. Use it to check what your app sent, such as the method or the JSON body. page.waitForEvent('requestfinished') resolves only after the response body has been fully downloaded.
const { test, expect } = require('@playwright/test'); test('check what the page sent with waitForRequest', async ({ page }) => { await page.goto('https://jsonplaceholder.typicode.com/'); const requestPromise = page.waitForRequest(req => req.url().endsWith('/posts') && req.method() === 'POST' ); await page.evaluate(() => fetch('/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'hello', userId: 7 }), }) ); const request = await requestPromise; expect(request.postDataJSON()).toEqual({ title: 'hello', userId: 7 }); }); test('wait until the request has fully finished', async ({ page }) => { await page.goto('https://jsonplaceholder.typicode.com/'); const finishedPromise = page.waitForEvent('requestfinished', req => req.url().endsWith('/todos/1') ); await page.evaluate(() => fetch('/todos/1')); const request = await finishedPromise; const response = await request.response(); expect(response.status()).toBe(200); expect(request.timing().responseEnd).toBeGreaterThan(0); });
| Method | Resolves when | Gives you |
|---|---|---|
page.waitForRequest() | The request is sent | Request: method(), postDataJSON(), headers() |
page.waitForResponse() | Response headers arrive | Response: status(), json() |
page.waitForEvent('requestfinished') | The body has finished downloading | Request; call request.response() for the response |
page.waitForEvent('requestfailed') | The request fails (DNS, aborted, etc.) | Request with failure() |
For most tests, waitForResponse is the one you want. Reach for requestfinished when you need the timing data or when a large download must be complete before the next step.
Playwright: Wait for Network Request to Finish
If you searched for "playwright wait for network request to finish", you probably want one of two things.
One specific request
Use the pattern above. waitForResponse resolves when the response arrives; waitForEvent('requestfinished') resolves when the body is fully read. Both are exact and fast.
"All" network requests
The tempting answer is networkidle:
await page.goto('https://demo.playwright.dev/api-mocking/', { waitUntil: 'networkidle' });
It works on this small demo, but the Playwright API docs mark 'networkidle' as DISCOURAGED: it waits until there are no network connections for at least 500 ms. Pages with analytics beacons, polling, websockets or live chat may never reach that, and every page pays a 500 ms tax even when it is ready. The waitForLoadState guide covers this in detail.
The better approach: list the requests your test actually depends on and wait for each one. Start all the promises first, trigger the requests, then Promise.all:
const { test, expect } = require('@playwright/test'); test('wait for several specific requests to finish', async ({ page }) => { await page.goto('https://jsonplaceholder.typicode.com/'); const usersPromise = page.waitForResponse('**/users'); const postsPromise = page.waitForResponse('**/posts?userId=1'); await page.evaluate(() => { fetch('/users'); fetch('/posts?userId=1'); }); const [users, posts] = await Promise.all([usersPromise, postsPromise]); expect(users.ok()).toBeTruthy(); expect(await posts.json()).toHaveLength(10); });
Often you don't need any network wait at all. If the data ends up on screen, a web assertion such as await expect(page.getByRole('listitem')).toHaveCount(17) retries until it is true. That is the auto-waiting approach, and it tests what the user sees. Use waitForResponse when the response itself is what you want to check, or when nothing visible changes.
Using waitForResponse with page.route Mocks
waitForResponse keeps working when you mock the API. A response fulfilled by route.fulfill() still fires a response event, so you can wait on it and read your fake body:
const { test, expect } = require('@playwright/test'); test('waitForResponse also sees mocked responses', async ({ page }) => { await page.route('**/api/v1/fruits', route => route.fulfill({ json: [{ name: 'Mocked Mango', id: 1 }] }) ); const responsePromise = page.waitForResponse('**/api/v1/fruits'); await page.goto('https://demo.playwright.dev/api-mocking/'); const response = await responsePromise; expect(await response.json()).toEqual([{ name: 'Mocked Mango', id: 1 }]); await expect(page.getByRole('listitem')).toHaveText(['Mocked Mango']); });
Register the route before goto, or the real request may go out first. More mocking patterns, including changing a real response and blocking requests, are in the network interception guide.
Timeouts and Common Mistakes
When nothing matches, the error looks like this:
TimeoutError: page.waitForResponse: Timeout 4000ms exceeded while waiting for event "response" =========================== logs =========================== waiting for response "api/v1/fruits" ============================================================
How long it waits depends on where the timeout comes from. In Playwright Test 1.62:
{ timeout: 5000 }on the call wins over everything else.0disables it.actionTimeoutinuse(config ortest.use) also applies towaitForResponse.- Neither set: in Playwright Test it waits until the test timeout (30 seconds by default) and fails with "Test timeout of 30000ms exceeded".
The mistakes we see most often:
- Awaiting the wait after the click. Start the promise first.
- Partial URL strings.
'api/v1/fruits'never matches. Use'**/api/v1/fruits', a RegExp or a predicate. - Matching too broadly.
'**/api/**'resolves on the first API call of any kind. Be specific. - Assuming success. A 500 resolves the wait. Assert the status.
- Using
waitForTimeoutinstead. A fixed sleep is either too short (flaky) or too long (slow). See fixing Playwright timeout errors and the flaky tests guide.
When a network wait fails and you can't see why, open the trace. The trace viewer shows every request with its timing next to each action, so you can see whether the response came before or after you started waiting.
Frequently Asked Questions
What does page.waitForResponse do in Playwright?
It returns a promise that resolves with the first network response whose URL matches a string, glob or regular expression, or for which your predicate function returns true. You then read status(), headers() or json() from it. It waits only for responses that arrive after you call it.
Why does waitForResponse time out even though the request happened?
Usually because you started waiting after the action that sent the request. If the response already arrived, waitForResponse keeps waiting for the next one. Store the promise first (no await), then click or navigate, then await the promise.
How do I wait for all network requests to finish in Playwright?
You can use page.waitForLoadState('networkidle'), but Playwright marks networkidle as discouraged for testing because apps that poll or stream never go idle. Wait for the specific responses the test depends on with waitForResponse, or assert on what the user sees with expect.
Does waitForResponse resolve on 404 or 500 errors?
Yes. Any response that matches resolves the promise, including 4xx and 5xx. Check response.status() or response.ok() yourself, or put status() === 200 inside a predicate if you only want a successful response.
What is the difference between waitForRequest and waitForResponse?
waitForRequest resolves when the browser sends a matching request, so you can check its method, headers or postDataJSON(). waitForResponse resolves when the response headers arrive, so you can check the status and body. Use page.waitForEvent('requestfinished') if you need the body fully downloaded.
Does waitForResponse work with mocked routes?
Yes. When page.route fulfills a request with route.fulfill, Playwright still emits a response event, so waitForResponse resolves with your mocked status and body.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.
Flaky Tests Start Here
Your Tests Aren't Flaky. Your Waits Are.
Most flaky Playwright tests come down to waiting for the wrong thing: a fixed timeout, networkidle, or a waitForResponse started one line too late. The sales page below shows the waits that cause it and how the Playwright + Claude AI course teaches you to fix them for good, in JavaScript.
- When to use auto-waiting, web assertions and network waits
- API testing and mocking with page.route
- Claude AI + Playwright MCP Server to find and fix flaky tests
- 4.5★ on Udemy, 510+ students, 30-day money-back guarantee
Ready now? Enroll on Udemy · Know your waits? Take the practice tests