QA How-To
How to Capture network traffic in Playwright (2026)
Learn playwright how to capture network traffic using request and response events, route handlers, HAR files, assertions, tracing, and reliable debugging.
17 min read | 2,631 words
TL;DR
Start `page.waitForRequest()` before the triggering action, then await the returned Request and assert its URL, method, query, body, or safe headers. The API observes issuance only; use `waitForResponse()` or a UI assertion when server success or completed behavior is the requirement.
Key Takeaways
- Create the request promise before the action so a fast browser request cannot be missed.
- Match URL plus method, query, payload, resource type, or business identity as needed.
- Use URLSearchParams and postDataJSON instead of fragile raw string comparisons.
- Treat request issuance separately from response success and final UI state.
- Use route APIs to modify traffic, listeners for collections, and APIRequestContext for direct API tests.
- Account for separate redirect Request objects and Page versus BrowserContext scope.
- Keep secrets out of header and payload diagnostics, especially in CI logs.
playwright how to capture network traffic is a practical Playwright workflow that depends on choosing the right event, locator, assertion, and diagnostic evidence for the scenario.
Playwright network traffic capture waits for the next matching network request issued by a Page and returns a Request object. Start the wait before the action that can trigger the request, match narrowly by URL plus method or payload, and then assert the request's business contract. Use waitForResponse when success status or response data is the real requirement.
This method is valuable for proving browser-to-service wiring: the right endpoint was called, query parameters were encoded, a JSON payload contains the intended values, or a client header was sent. It observes traffic without modifying it. This guide covers the current 2026 signature, reliable event ordering, request inspection, redirects, timeouts, scope, and maintainable helper design.
TL;DR
const requestPromise = page.waitForRequest(request =>
request.url().endsWith('/api/orders') && request.method() === 'POST',
);
await page.getByRole('button', { name: 'Place order' }).click();
const request = await requestPromise;
expect(request.postDataJSON()).toMatchObject({
sku: 'PW-42',
quantity: 2,
});
| Need | Best API | Reason |
|---|---|---|
| Observe one outgoing request | page.waitForRequest() |
Returns the matching Request |
| Prove server status or body | page.waitForResponse() |
Returns the Response |
| Modify, fulfill, or abort traffic | page.route() or context.route() |
A wait is read-only |
| Collect many requests over time | page.on('request') with cleanup |
One wait resolves once |
| Exercise an endpoint without browser UI | APIRequestContext |
Direct API testing is clearer |
The most common bug is event ordering. If the click is awaited before the request wait is created, a fast request can already be gone.
1. playwright how to capture network traffic: Understand the Core Contract
The signature accepts a URL string, a regular expression, or a predicate that receives a Playwright Request. The predicate may return a boolean or a Promise of a boolean. The call returns Promise<Request> when the first matching request is issued.
const exact = page.waitForRequest('https://api.example.test/v1/profile');
const pattern = page.waitForRequest(/\/v1\/profile(?:\?|$)/);
const predicate = page.waitForRequest(request =>
request.url().includes('/v1/profile') && request.method() === 'PATCH',
);
Only one of these should normally be active for one trigger. A string communicates exact identity, a regular expression handles a controlled family of URLs, and a predicate can inspect URL, HTTP method, resource type, headers, or body.
The event represents request issuance. It does not mean the server accepted the request, sent response headers, completed the body, or updated application state. Browser network events follow a lifecycle: request, response when available, then request finished after the body downloads. Network failures produce requestfailed instead of a normal finished event. HTTP 404 and 500 responses are still HTTP responses, not transport failures.
waitForRequest is Page-scoped. It observes requests attributed to that Page. A popup's initial document request can require BrowserContext-level observation because the popup Page becomes available only after that request has started. Scope is part of correctness, not merely syntax.
2. playwright how to capture network traffic: Build the First Reliable Test
Create the promise, perform one trigger, then await the promise. This runnable test controls both the application document and API response:
import { test, expect } from '@playwright/test';
test('sends a request when an order is placed', async ({ page, context }) => {
await context.route('https://shop.example.test/cart', route => route.fulfill({
contentType: 'text/html',
body: `
<button>Place order</button><p role="status"></p>
<script>
document.querySelector('button').addEventListener('click', async () => {
const response = await fetch('https://shop.example.test/api/orders', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sku: 'PW-42', quantity: 2 })
});
document.querySelector('[role="status"]').textContent =
response.ok ? 'Order accepted' : 'Order failed';
});
</script>
`,
}));
await context.route('https://shop.example.test/api/orders', route => {
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'ORDER-81' }),
});
});
await page.goto('https://shop.example.test/cart');
const requestPromise = page.waitForRequest(request =>
request.url() === 'https://shop.example.test/api/orders' &&
request.method() === 'POST',
);
await page.getByRole('button', { name: 'Place order' }).click();
const request = await requestPromise;
expect(request.postDataJSON()).toEqual({ sku: 'PW-42', quantity: 2 });
await expect(page.getByRole('status')).toHaveText('Order accepted');
});
The route makes the test deterministic, while the wait remains observational. route.fulfill() controls the reply. waitForRequest() proves what the browser sent. The final UI assertion proves the client consumed the reply.
Do not move waitForRequest() after the click. The request can be emitted synchronously by the click handler, before the action promise returns.
3. Select string, regular expression, or predicate matching
Choose the narrowest matcher that survives irrelevant changes.
| Matcher | Good use | Risk |
|---|---|---|
| Exact string | Fixed endpoint with fixed query | Breaks on legitimate query ordering or added parameters |
| Regular expression | Stable path with variable identifier | Broad patterns can match assets or unrelated versions |
| Predicate | Method, URL, body, header, or resource type matters | Complex predicates can hide intent or throw |
An exact string is readable for a stable endpoint:
const requestPromise = page.waitForRequest(
'https://api.example.test/v1/health',
);
A regular expression works for a variable entity:
const requestPromise = page.waitForRequest(
/https:\/\/api\.example\.test\/v1\/users\/[^/?]+$/,
);
A predicate is usually best for mutations:
const requestPromise = page.waitForRequest(request => {
const url = new URL(request.url());
return url.origin === 'https://api.example.test' &&
url.pathname === '/v1/users/42' &&
request.method() === 'PATCH';
});
Parse URLs instead of comparing raw query strings. Query parameter order is not business meaning, and values can be percent-encoded. Match origin when requests could go to more than one environment or third party. Matching only url().includes('/users') can accidentally accept an avatar, analytics call, or list refresh.
Keep predicates pure and quick. Do not perform unrelated I/O, mutate test state, or log every request inside them. An asynchronous predicate is supported, but most matching data is already available synchronously on Request.
4. Inspect URL and query parameters robustly
Use the platform URL class for path and query assertions:
const searchPromise = page.waitForRequest(request => {
const url = new URL(request.url());
return url.pathname === '/api/search' && request.method() === 'GET';
});
await page.getByLabel('Search').fill('playwright video');
await page.getByRole('button', { name: 'Search' }).click();
const searchRequest = await searchPromise;
const searchUrl = new URL(searchRequest.url());
expect(searchUrl.searchParams.get('q')).toBe('playwright video');
expect(searchUrl.searchParams.get('page')).toBe('1');
expect(searchUrl.searchParams.getAll('tag')).toEqual(['qa', 'automation']);
This separates matching from complete validation. The predicate identifies one search request by path and method. Assertions then report exactly which query contract failed.
Be deliberate about optional parameters. If sort order is not under test, do not assert it merely because the current client sends one. If it is part of the API contract, assert its value. Over-asserting implementation details makes UI tests expensive to maintain, while under-asserting lets malformed requests pass.
For identifiers, decide whether the UI should send a raw value or an encoded one. URLSearchParams gives decoded values, which is normally what the test wants. Check the raw URL only when encoding itself is the subject.
A request assertion does not replace search result assertions. Prove the request and visible results when the end-to-end wiring is in scope. For a narrow UI unit or component test, request shape alone may be sufficient if the response is controlled.
5. Assert JSON and form request bodies
request.postDataJSON() parses JSON request bodies and form-urlencoded data. It returns serializable data or null, and invalid non-form, non-JSON content can throw during parsing. Match content type and method before using it.
type ProfileUpdate = {
displayName: string;
notifications: boolean;
};
const updatePromise = page.waitForRequest(request =>
request.url().endsWith('/api/profile') && request.method() === 'PATCH',
);
await page.getByLabel('Display name').fill('QA Engineer');
await page.getByLabel('Email notifications').check();
await page.getByRole('button', { name: 'Save profile' }).click();
const request = await updatePromise;
const payload = request.postDataJSON() as ProfileUpdate;
expect(payload).toEqual({
displayName: 'QA Engineer',
notifications: true,
});
Use toMatchObject() when the test owns only part of a larger payload, and toEqual() when the complete shape is the contract. Avoid snapshots for small request bodies because focused assertions produce clearer diffs and do not normalize accidental fields into an approved snapshot.
request.postData() returns the body as text, while postDataBuffer() returns binary form. Use those for text formats or multipart diagnostics, but do not decode arbitrary binary uploads as UTF-8. For file uploads, assert metadata, endpoint, method, and relevant multipart markers at the appropriate layer rather than embedding large file contents in test logs.
Never print an entire mutation body by default. Passwords, tokens, addresses, and regulated values can leak to CI logs. Create a redacted diagnostic projection when failure analysis requires payload context.
6. Verify headers without exposing secrets
request.headers() returns a synchronous object with lower-cased header names and intentionally omits certain security-related headers. request.allHeaders() returns all headers asynchronously, including cookie information. request.headerValue(name) performs a case-insensitive lookup and returns a promise.
For a non-sensitive client contract:
const requestPromise = page.waitForRequest(request =>
request.url().endsWith('/api/export') && request.method() === 'POST',
);
await page.getByRole('button', { name: 'Export CSV' }).click();
const request = await requestPromise;
expect(request.headers()['content-type']).toContain('application/json');
expect(await request.headerValue('x-client-version')).toMatch(/^web-/);
Prefer headerValue() when one header matters. Avoid allHeaders() unless security-related headers are explicitly under test, because a failure dump can expose cookies or credentials. Even when authorization is the requirement, assert a safe property such as presence or scheme in a protected environment, not a full token value.
Remember that browser and infrastructure layers can add or normalize headers. A UI test should assert headers controlled by the client contract. Proxy, CDN, and server-added header validation belongs at another layer.
If request interception is needed to add or remove headers, waitForRequest is the wrong API. Use routing, and keep a separate observational assertion if you also need to prove the resulting request. The route continue override examples cover controlled request modification.
7. Choose request, response, route, or listener APIs
The network API names are similar but their responsibilities differ:
const requestPromise = page.waitForRequest(request =>
request.url().endsWith('/api/orders') && request.method() === 'POST',
);
const responsePromise = page.waitForResponse(response =>
response.url().endsWith('/api/orders') &&
response.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Place order' }).click();
const [request, response] = await Promise.all([
requestPromise,
responsePromise,
]);
expect(request.postDataJSON()).toMatchObject({ quantity: 2 });
expect(response.status()).toBe(201);
Both promises start before the trigger. This is appropriate when the test explicitly owns both outgoing shape and server result. Do not assert every layer in every test. A focused request-contract test can route a response and assert request shape, while a user journey can emphasize final UI state.
Use page.on('request', handler) to collect multiple requests or diagnose a sequence. Remove the listener after the scoped operation to avoid cross-test or later-step noise. Use page.route() or context.route() to fulfill, continue, or abort traffic. Routing stalls matching requests until the handler resolves, so every route must fulfill, continue, fallback, or abort.
Use APIRequestContext when the browser UI adds no value. The Playwright APIRequestContext guide covers direct endpoint testing, shared cookies, and response assertions.
8. Account for redirects and request chains
A server redirect creates a new Request. The original and destination requests are linked by redirectedFrom() and redirectedTo(). Decide which member of the chain the test should observe.
const destinationPromise = page.waitForRequest(request =>
request.url() === 'https://app.example.test/dashboard' &&
request.isNavigationRequest(),
);
await page.goto('https://app.example.test/start');
const destination = await destinationPromise;
const source = destination.redirectedFrom();
expect(source).not.toBeNull();
expect(source!.url()).toBe('https://app.example.test/start');
In a real runnable test, the server or route for /start must return a redirect to /dashboard. Match isNavigationRequest() when document navigation distinguishes the request from an API fetch to the same path.
Do not assume one Request object changes its URL through the chain. Each redirected request has its own method, URL, headers, response, and links. A 301 or 302 can also affect method behavior according to browser rules, so assert the member that represents the product contract.
For an authentication redirect, final URL and visible authorized state are usually stronger than inspecting every hop. Inspect the chain when preserving a return URL, HTTP-to-HTTPS upgrade, or redirect target is specifically under test.
9. Match duplicate and concurrent requests precisely
Applications often send the same endpoint more than once because of prefetch, retries, pagination, polling, or multiple components. waitForRequest() resolves with the first match. A broad matcher can capture the wrong call.
Disambiguate with method, query, body, resource type, or a correlation value:
const pageTwoPromise = page.waitForRequest(request => {
const url = new URL(request.url());
return url.pathname === '/api/issues' &&
url.searchParams.get('page') === '2' &&
request.method() === 'GET' &&
request.resourceType() === 'fetch';
});
await page.getByRole('button', { name: 'Next page' }).click();
const request = await pageTwoPromise;
expect(new URL(request.url()).searchParams.get('page')).toBe('2');
Do not use an incrementing global counter inside the predicate to select "the second request." Parallel component behavior makes event order unstable. Match business identity.
When the requirement is an exact count, attach a listener before the operation, collect matching Requests, wait for the final UI outcome, then remove the listener and assert the collection. One-shot waiting is not a counting API.
Avoid creating many unawaited request promises. A promise that never matches can later reject on timeout and produce an unhandled rejection. Create only the waits the test will consume, and await them on every path.
10. Handle timeout and no-request failures
The method's default timeout is 30 seconds. Pass a local value or change Page default timeout with page.setDefaultTimeout(). Passing zero disables the method timeout, but the outer Playwright Test timeout still applies.
const request = await page.waitForRequest(
candidate => candidate.url().endsWith('/api/report') &&
candidate.method() === 'POST',
{ timeout: 10_000 },
);
In ordinary event-first code, start the promise before the action instead of awaiting it immediately as shown in that isolated signature. A timeout means no Page request satisfied the matcher within the budget. Possible causes include:
- the action never happened or failed validation,
- the request URL, method, or body changed,
- another Page or a service worker owns the request,
- cache prevented a network request,
- the application failed before issuing traffic,
- the predicate threw or was too narrow.
Do not solve the failure by changing 30 seconds to 90 seconds without evidence. Capture a trace, inspect safe network metadata, verify the action's UI result, and compare the matcher with actual traffic. The Playwright timeout troubleshooting article explains overlapping timeout layers.
Never catch the timeout and silently continue. If a request is optional, use a listener and explicit branch tied to the requirement, or redesign the assertion. A required request that never happens should fail clearly.
11. Scope requests from popups, frames, and service workers
Frame requests belong to their Page, so a Page wait can observe requests from child frames associated with that Page. Use request.frame() only when the request is frame-owned and a frame distinction matters. Some navigation and service-worker requests do not have an available Frame, so do not call frame() blindly in a universal predicate.
For a popup's initial navigation, listen at BrowserContext scope before opening it:
const initialRequestPromise = context.waitForEvent('request', {
predicate: request =>
request.url() === 'https://reports.example.test/summary' &&
request.isNavigationRequest(),
});
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Open summary' }).click();
const [initialRequest, popup] = await Promise.all([
initialRequestPromise,
popupPromise,
]);
expect(initialRequest.url()).toBe('https://reports.example.test/summary');
await expect(popup.getByRole('heading', { name: 'Summary' })).toBeVisible();
Context scope is necessary because the popup Page event occurs only after the initial response starts loading. For later requests issued by the popup, use popup.waitForRequest().
Service workers complicate attribution. request.serviceWorker() is Chromium-specific and returns null in other browsers. Keep cross-browser tests based on business evidence unless service-worker behavior itself is the requirement. If routing appears not to see expected requests, review whether service workers control them and configure the test environment deliberately.
12. Build a reusable Playwright network traffic capture helper
A helper should capture business identity without hiding the event-first sequence. Return the Playwright Request so the caller can make focused assertions:
import type { Page, Request } from '@playwright/test';
export function waitForJsonMutation(
page: Page,
pathname: string,
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE',
): Promise<Request> {
return page.waitForRequest(request => {
const url = new URL(request.url());
const contentType = request.headers()['content-type'] ?? '';
return url.pathname === pathname &&
request.method() === method &&
contentType.includes('application/json');
});
}
const updatePromise = waitForJsonMutation(page, '/api/profile', 'PATCH');
await page.getByRole('button', { name: 'Save profile' }).click();
const update = await updatePromise;
expect(update.postDataJSON()).toMatchObject({ notifications: true });
This helper does not click, await a response, or assert a payload. The test keeps one visible trigger and owns the business expectation. A giant clickAndWaitForAnyApiCall() abstraction would obscure causality and accept unrelated traffic.
Review matcher breadth, secret handling, page scope, and timeout policy in helper code. Avoid any return types and preserve the Request type. Keep endpoint paths centralized only when they are genuine test contracts, not when centralization makes simple tests harder to read.
Interview Questions and Answers
Q: What does page.waitForRequest() return?
It returns the first Playwright Request matching a string, regular expression, or predicate. The event occurs when the request is issued. It does not prove a successful response or completed UI update.
Q: Why must the wait start before the click?
A click handler can issue fetch or XHR traffic immediately. If the test awaits the click first, the request event can occur before the listener exists. I create the promise, perform one trigger, and then await the promise.
Q: How do you validate a JSON POST body?
I match URL and POST method, await the Request, then call postDataJSON() and assert the owned fields. I verify content type when parsing assumptions matter and avoid logging sensitive body values.
Q: When should you use waitForResponse() instead?
Use it when status, headers, or response body is the requirement. I may start both request and response waits before one action when a focused integration test owns both outgoing shape and server outcome.
Q: How do redirects appear in Playwright?
Each redirect hop is a separate Request. The objects are linked through redirectedFrom() and redirectedTo(). I match and assert the specific hop that represents the contract.
Q: How do you avoid matching the wrong duplicate request?
I parse the URL and add method, query, body, resource type, or business identifier constraints. I never rely on "the second request" when concurrent components can change order.
Q: Can waitForRequest() intercept or change traffic?
No. It observes and returns a Request. I use Page or BrowserContext routing to continue, fulfill, or abort traffic, and keep observational assertions separate.
Q: What is the default timeout?
The documented default is 30 seconds. A call can pass a different timeout, Page default timeout can change it, and zero disables the method timeout. The overall test timeout still bounds Playwright Test execution.
Common Mistakes
- Creating the request wait after the action already emitted traffic.
- Matching only a short URL substring that also appears in assets or analytics.
- Ignoring the HTTP method for endpoints shared by reads and mutations.
- Comparing raw query strings when parameter order is irrelevant.
- Parsing every body with
postDataJSON()without checking its format. - Dumping tokens, cookies, or personal data into failure logs.
- Using a request event as proof of a successful server response.
- Using a one-shot wait when the requirement is a count or sequence.
- Assuming redirect hops mutate one Request object.
- Waiting on the opener Page for later popup traffic.
- Calling
request.frame()for requests that may not have a Frame. - Increasing timeouts before checking scope, cache, matcher, and application behavior.
- Catching a timeout and continuing as if the request were optional.
- Leaving extra request promises unawaited.
Conclusion
Playwright waitForRequest is a precise way to verify outgoing browser traffic. Register it before the trigger, identify the request by business-relevant URL, method, query, body, or header data, and remember that request issuance is only one stage of the flow.
Start with one mutation test. Match the endpoint and method, assert a safe payload projection, and keep the final UI or response assertion that proves completion. Use routing for modification, listeners for collections, and APIRequestContext when no browser behavior is involved.
Interview Questions and Answers
Explain the reliable waitForRequest event sequence.
I create the request promise before the exact action that triggers traffic. I perform one action, await the Request, and assert business-relevant fields. If completion matters, I also assert a response or final UI state.
How do you choose between a string, RegExp, and predicate matcher?
I use an exact string for a truly fixed URL, a regular expression for a controlled variable path, and a predicate when method, query, body, headers, or resource type matters. The matcher should be narrow enough to exclude unrelated requests but stable against irrelevant changes.
How do you assert a JSON request body safely?
I first match endpoint, mutation method, and content type. Then I call `postDataJSON()` and assert the specific synthetic fields owned by the test. I avoid dumping full bodies that may contain credentials or personal data.
When would you start both request and response waits?
I use both in a focused browser-to-service wiring test that owns outgoing shape and server outcome. Both promises must start before the trigger. In broader journeys, a final UI assertion may be more valuable than duplicating every network check.
How do you handle several calls to the same endpoint?
I match business identity through method, decoded query, final payload, resource type, or a safe correlation value. I never select the second or last event by timing. For counts or sequences, I use a scoped listener and remove it after the operation.
What changes when the request belongs to a popup?
Later popup traffic can use `popup.waitForRequest()`. For the popup's initial document request, I register a BrowserContext request event wait before opening because the popup Page is surfaced only after the response starts.
Why is waitForRequest not an interception API?
It observes a Request and resolves a promise but does not stall or mutate traffic. Routing APIs own fulfillment, continuation, and abortion. Keeping these responsibilities separate makes tests easier to reason about.
How would you diagnose a waitForRequest timeout?
I confirm the action occurred, compare actual safe traffic with the matcher, and check Page scope, cache, service-worker ownership, validation, and application errors. I preserve the trace and original failure. Increasing the timeout is appropriate only when the request is expected but legitimately slower.
Frequently Asked Questions
What does Playwright network traffic capture return?
It returns the first Playwright Request that matches a URL string, regular expression, or predicate. The request has been issued, but a successful response or completed application update is not implied.
Should waitForRequest be called before or after click?
Create the promise before the click, then perform the action and await the promise. A click handler can issue traffic immediately, so creating the wait afterward introduces a race.
How do I wait for a POST request in Playwright?
Use a predicate that matches the endpoint and `request.method() === 'POST'`. After capture, call `postDataJSON()` for JSON or form-urlencoded bodies and assert the fields owned by the scenario.
How do I assert query parameters in a Playwright request?
Create a `URL` from `request.url()` and inspect `searchParams`. This decodes values and avoids treating harmless query parameter ordering as a failure.
What is the difference between waitForRequest and waitForResponse?
`waitForRequest` returns outgoing request data as soon as the request is issued. `waitForResponse` returns the matching response and is appropriate for status, response headers, or response body assertions.
Can waitForRequest mock an API?
No. It is observational and does not modify traffic. Use Page or BrowserContext routing to fulfill, continue, or abort requests, and keep a wait if you also need to assert the outgoing request.
What is the default waitForRequest timeout?
The documented default is 30 seconds. You can pass a call-specific timeout, change the Page default timeout, or pass zero to disable the method timeout. The overall test timeout can still end the test.
How do redirects affect waitForRequest?
Every server redirect creates a new Request object. Match the specific source or destination request and traverse the chain with `redirectedFrom()` or `redirectedTo()` when redirect behavior is part of the contract.
Related Guides
- How to Use Playwright waitForRequest (2026)
- How to Capture network traffic in Cypress (2026)
- How to Capture network traffic in Selenium (2026)
- How to Debug a failing test in VS Code in Playwright (2026)
- How to Run tests in headed mode in Playwright (2026)
- How to Scroll to an element in Playwright (2026)