QA How-To
Playwright route continue with override: Examples and Best Practices
Learn Playwright route continue with override examples for headers, methods, URLs, and request bodies, plus reliable patterns, tests, and debugging tips.
19 min read | 2,673 words
TL;DR
route.continue({ url, method, postData, headers }) sends an intercepted request to the real network with selected fields changed. It ends routing immediately, so use route.fallback() instead when handlers must be composed.
Key Takeaways
- Use route.continue() when the modified request should reach the real network immediately.
- Override only url, method, postData, or headers, and preserve every request field that should remain unchanged.
- Build header overrides from request.headers() so required application headers are not discarded accidentally.
- Use browserContext.addCookies() for cookies because browsers can ignore forbidden header overrides.
- Choose route.fallback() when another matching handler must get a chance to process the request.
- Register routes before the action that triggers the request, then assert both the request and the visible outcome.
The best playwright route continue with override examples change one deliberate part of a request, preserve everything else, and prove that the application responded to the modified request. In Playwright for Node.js, route.continue() can override url, method, postData, and headers before sending the request to the real network.
That makes the API useful for testing tenant headers, feature flags, alternate endpoints, legacy clients, and negative server behavior without changing production code. The hard part is not the syntax. It is controlling route scope, redirects, handler order, request serialization, and assertions so the test remains trustworthy. This guide uses Playwright Test with TypeScript and current APIs.
TL;DR
await page.route('**/api/orders', async route => {
const request = route.request();
await route.continue({
headers: {
...request.headers(),
'x-test-tenant': 'tenant-b',
},
});
});
| Goal | Route action | Network used? | Other matching handlers run? |
|---|---|---|---|
| Send the original request | route.continue() |
Yes | No |
| Send a modified request | route.continue(overrides) |
Yes | No |
| Pass control through handler layers | route.fallback(overrides) |
Eventually | Yes |
| Return a controlled response | route.fulfill(options) |
No, unless paired with route.fetch() |
No |
| Simulate a network failure | route.abort(errorCode) |
No | No |
1. Playwright route continue with override examples: Core behavior
A route handler is installed with page.route() or browserContext.route(). When a matching browser request starts, Playwright pauses it and passes a Route to the handler. Calling route.continue() releases that request to the network. Passing an options object replaces only the supplied request properties.
The supported overrides are intentionally narrow. url changes the destination, but the new URL must use the same protocol as the original. method replaces the HTTP verb. postData replaces the outgoing payload and accepts a string, Buffer, or serializable value. headers replaces the header collection used for the routed request. Header values are converted to strings.
A crucial semantic detail is that continue() is terminal for routing. Once it runs, Playwright does not invoke another route handler that also matches the request. That is different from middleware in many web frameworks. If a shared handler must add a header and a later handler must decide whether to mock the response, use route.fallback() for the shared layer.
The route match is based on the original request URL. Changing url inside the handler does not cause Playwright to rematch the request against route patterns. This prevents recursive routing, but it also means a handler for the destination URL will not be selected after the override. Plan handler ownership around the original URL.
2. Minimal setup for Playwright intercept request tests
Install Playwright Test and browsers in a Node.js project, then keep network examples in a normal spec file. The following commands create the required runtime without assuming a particular application framework.
npm init playwright@latest
npx playwright install
A route must be registered before navigation, a click, or another action triggers the target request. Otherwise the browser can send the request before interception exists. Use a narrow URL glob and inspect the method in the callback when an endpoint supports several operations.
import { test, expect } from '@playwright/test';
test('sends a test tenant header', async ({ page }) => {
await page.route('**/api/orders', async route => {
const request = route.request();
if (request.method() !== 'GET') {
await route.continue();
return;
}
await route.continue({
headers: {
...request.headers(),
'x-test-tenant': 'tenant-b',
},
});
});
const observed = page.waitForRequest(request =>
request.url().includes('/api/orders') &&
request.headers()['x-test-tenant'] === 'tenant-b'
);
await page.goto('https://app.example.test/orders');
await observed;
await expect(page.getByRole('heading', { name: 'Orders' })).toBeVisible();
});
The domain is illustrative, so replace it with an environment you control. The structure is runnable in a real Playwright project once the application URL exists. Notice that setup is awaited and the request promise is created before navigation. This avoids a race between observation and the network event. For broader routing architecture, compare the controlled-response patterns in Playwright route fulfill.
3. Override headers without deleting required values
Passing headers supplies the routed request's header set. The safest routine is to start with request.headers(), spread the result, and change only the test header. Replacing the entire object with one custom header can remove accept, authorization, trace, or application-specific headers and create a failure unrelated to the behavior under test.
import { test, expect } from '@playwright/test';
test('requests the beta representation', async ({ page }) => {
await page.route('**/api/profile', async (route, request) => {
const headers = {
...request.headers(),
'x-feature-variant': 'beta',
};
delete headers['x-internal-debug'];
await route.continue({ headers });
});
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/profile') && response.request().method() === 'GET'
);
await page.goto('https://app.example.test/profile');
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(page.getByTestId('profile-layout')).toHaveAttribute('data-variant', 'beta');
});
Property names returned by request.headers() are lowercased in Playwright's Node.js API, so use lowercase keys when deleting or reading them. To remove a header, deleting it from a copied object is clear and TypeScript-friendly. An undefined value is also supported by the routing API in JavaScript, but the exact TypeScript type available can depend on the installed Playwright version.
Do not rely on overriding forbidden browser headers such as Cookie, Host, or Content-Length. Browser networking rules may ignore those changes. Use browserContext.addCookies() to establish cookie state. Let the browser calculate content length after a body change. The headers override also applies to redirects initiated by the routed request, unlike url, method, and postData, which apply only to the original request.
4. Change query parameters or the request destination
A URL override is useful when the UI constructs a request that is almost right for a test but needs a deterministic query, locale, shard, or same-protocol destination. Construct the new value with the standard URL class instead of string concatenation. That preserves existing parameters and handles encoding correctly.
import { test, expect } from '@playwright/test';
test('forces a stable catalog page size', async ({ page }) => {
await page.route('**/api/catalog**', async route => {
const url = new URL(route.request().url());
url.searchParams.set('limit', '10');
url.searchParams.set('sort', 'name');
await route.continue({ url: url.toString() });
});
const responsePromise = page.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/api/catalog' && url.searchParams.get('limit') === '10';
});
await page.goto('https://app.example.test/catalog');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
await expect(page.getByRole('listitem')).toHaveCount(10);
});
The destination must keep the original protocol. An HTTPS request can be redirected to another HTTPS host or path, but not to HTTP through this override. Certificates, DNS, authentication, CORS, and server availability still apply because the request goes to the real network.
The response and request events normally expose the final network URL, which is useful for assertions. However, routing remains associated with the original match. Avoid broad **/* handlers that rewrite every asset, analytics request, and API call. A precise endpoint glob reduces accidental modifications and keeps parallel test behavior understandable. If a fake response is the actual goal, route fulfill examples and best practices are a better fit than redirecting traffic to an ad hoc service.
5. Override method and postData safely
Changing an HTTP method is valid, but method and body need to make sense together. Turning a GET into a POST without a content type or valid server contract rarely tests realistic behavior. Prefer a test that starts from a request already close to the scenario, then override the smallest required fields.
import { test, expect } from '@playwright/test';
test('replays draft save as a publish request', async ({ page }) => {
await page.route('**/api/articles/42', async route => {
const request = route.request();
const original = request.postDataJSON() as { title: string; state: string };
const body = { ...original, state: 'published' };
await route.continue({
method: 'PUT',
postData: body,
headers: {
...request.headers(),
'content-type': 'application/json',
},
});
});
await page.goto('https://app.example.test/articles/42/edit');
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/articles/42') &&
response.request().method() === 'PUT'
);
await page.getByRole('button', { name: 'Save draft' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(page.getByText('Published')).toBeVisible();
});
request.postDataJSON() is convenient when the original body is JSON or form-encoded data that Playwright can parse. It throws or returns an unexpected shape when the body is not suitable, so use request.postData() for raw text and validate the result before transformation. A serializable object passed as postData is accepted by the routing API, but set content-type deliberately when server behavior depends on it.
Do not set content-length. The browser owns that header. Also remember that a method, URL, or body override does not carry over to redirected requests. A 307 or 308 response can have HTTP-defined method preservation behavior, but that is the browser processing a redirect, not Playwright reapplying your route overrides.
6. route.continue vs fallback, fulfill, fetch, and abort
Choosing the correct route action is more important than memorizing option syntax. Each action represents a different test boundary.
| API | Best use | Calls real server? | Can alter request? | Can alter response? |
|---|---|---|---|---|
route.continue() |
One terminal request mutation | Yes | Yes | No |
route.fallback() |
Layered route handling | Eventually | Yes | No |
route.fetch() |
Obtain a real response inside a handler | Yes | Yes | It returns a response for later use |
route.fulfill() |
Supply or finalize a response | Optional | No | Yes |
route.abort() |
Model transport failure or block resources | No | No | No |
Use continue() when one handler owns the request mutation and the application should talk to the actual backend. Use fallback() when cross-cutting logic, such as a correlation header, should flow into another matching handler. Matching handlers run in reverse registration order. That means the last registered handler receives the request first and can fall back to an earlier one.
Use route.fetch() plus route.fulfill({ response, ...overrides }) when the server should receive the request but the browser should see a modified response. Use fulfill() directly for deterministic mocks and abort() for a genuine transport-level failure. These are not interchangeable. A fulfilled 503 is an HTTP response, while abort('connectionrefused') is a network error. Applications often handle those states differently.
Because continue() skips all remaining matching handlers, a broad diagnostic handler registered earlier may never observe the request. If layered interception is intentional, write a small routing helper with explicit ownership and tests instead of depending on incidental registration order.
7. Compose handlers with route.fallback overrides
Suppose every API request needs a test-run identifier, while one endpoint sometimes returns a fixture. The shared handler should call fallback() so the endpoint-specific handler can still finish the route. Because Playwright evaluates matching handlers in reverse registration order, register the terminal or default handler first and the shared modifier last.
import { test, expect } from '@playwright/test';
test('layers a run header over an orders mock', async ({ page }) => {
await page.route('**/api/**', async route => {
if (new URL(route.request().url()).pathname === '/api/orders') {
expect(route.request().headers()['x-test-run']).toBe('run-42');
await route.fulfill({
status: 200,
json: [{ id: 7, total: 125 }],
});
return;
}
await route.continue();
});
await page.route('**/api/**', async route => {
await route.fallback({
headers: {
...route.request().headers(),
'x-test-run': 'run-42',
},
});
});
await page.goto('https://app.example.test/orders');
await expect(page.getByText('Order 7')).toBeVisible();
});
This example checks the modified header at the terminal handler, then returns controlled JSON. In a different test, that terminal handler could call continue() to use the network. A handler must resolve each matching route exactly once by continuing, falling back, fulfilling, or aborting. Forgetting to do so leaves the browser request paused and eventually causes a timeout.
Keep conditional branches explicit and return after resolving the route. This avoids accidental second calls such as fulfill() followed by continue(). For complex suites, expose purpose-specific helpers such as addRunHeader(page, id) instead of a generic callback that changes behavior through many flags.
8. Assert the override and the user-visible result
A test that only completes without error does not prove the override mattered. Strong network tests assert three layers when practical: what was sent, what came back, and what the user saw. The exact balance depends on the risk. A request assertion is essential for a header or payload transformation. A UI assertion proves that the application consumed the resulting state.
page.waitForRequest() and page.waitForResponse() are reliable when their promises are created before the triggering action. Predicate functions should match method and path, not only a loose substring. For request bodies, inspect postDataJSON() in the predicate or after the request resolves.
const requestPromise = page.waitForRequest(request => {
const url = new URL(request.url());
return url.pathname === '/api/search' && request.method() === 'POST';
});
await page.getByRole('button', { name: 'Search' }).click();
const request = await requestPromise;
expect(request.postDataJSON()).toEqual({
query: 'playwright',
includeArchived: true,
});
Be careful about what request event represents when routing has modified data. Assert at the closest observable boundary and confirm behavior through the response or UI. If exact request transformation is a business-critical adapter, move that transformation into a pure function and unit test it separately. The browser test can then verify the integration once.
Traces are valuable for timing, page state, and network metadata, but routing calls may not always appear as dedicated trace actions in recent Playwright versions. Add meaningful assertion messages or attach sanitized diagnostic data when a suite needs deeper evidence. Never log authorization values, session cookies, or personal data.
9. Isolation, cleanup, redirects, and service workers
Routes installed on a page or context live until the owner closes or the handler is removed. Playwright Test normally gives each test an isolated page and context, which prevents most leakage. If a test installs and removes routes within a long-lived page, call page.unroute() with the same URL matcher and, when needed, the same handler reference. Prefer per-test isolation over intricate cleanup.
Choose browserContext.route() when interception must cover every page in the context, including popups. Choose page.route() when the behavior belongs to one page only. Register context routes before creating or navigating pages. Do not mutate shared module variables inside handlers when workers run in parallel. Put scenario data in the test closure so each worker has an independent value.
Redirects have asymmetric behavior. Header overrides apply to the original routed request and redirects it initiates. URL, method, and postData overrides apply only to the original request. If redirect behavior is part of the requirement, assert each hop or use a controlled test server.
Service workers can intercept requests before page routing sees them. If an application uses a service worker and expected routes do not fire, create the context with serviceWorkers: 'block' for tests that specifically need Playwright routing. Treat that setting as a test design decision because blocking the worker changes production-like behavior. Also remember that enabling routing disables the HTTP cache, which can alter performance observations.
10. Playwright route continue with override examples for maintainable suites
A production suite benefits from small helpers that name intent and constrain mutation. The following helper adds headers but cannot unexpectedly rewrite methods or bodies. It accepts a Page, installs one endpoint-scoped handler, and returns the handler so a long-lived test can remove it.
import type { Page, Route } from '@playwright/test';
export async function addRequestHeaders(
page: Page,
url: string,
extraHeaders: Record<string, string>,
): Promise<(route: Route) => Promise<void>> {
const handler = async (route: Route): Promise<void> => {
await route.continue({
headers: {
...route.request().headers(),
...extraHeaders,
},
});
};
await page.route(url, handler);
return handler;
}
Use it from a test with data local to that test.
const handler = await addRequestHeaders(page, '**/api/invoices', {
'x-test-account': 'account-17',
});
await page.goto('https://app.example.test/invoices');
await expect(page.getByRole('heading', { name: 'Invoices' })).toBeVisible();
await page.unroute('**/api/invoices', handler);
Keep helpers focused, typed, and free of hidden environment branching. A route utility should not silently mock in local development and continue in CI. That makes failures hard to reproduce. Pass scenario behavior explicitly and assert its effect.
Review route patterns during API migrations. A handler that stops matching can allow a real request to pass, producing a false positive if the UI still shows cached or default data. Add a counter or request assertion when interception itself is part of the test premise. A related Playwright scenario interview guide can help teams evaluate whether candidates understand these tradeoffs instead of recalling syntax alone.
Interview Questions and Answers
Q: What does route.continue() do in Playwright?
It sends the intercepted request to the real network. An optional object can replace the request URL, method, body, or headers. It also ends route processing immediately, so no other matching route handler runs afterward.
Q: How do you add one header without losing existing headers?
Read route.request().headers(), spread that object into a new object, and add the new lowercase header key. Then pass the complete object to route.continue({ headers }). This preserves authorization and content negotiation headers that the application already set.
Q: Why would you use route.fallback() instead of route.continue()?
Use fallback() when another matching handler must process the request. It can also pass request overrides to the next handler. continue() skips the remaining handler chain and sends the request to the network immediately.
Q: Can route.continue() change an HTTPS request to HTTP?
No. A URL override must keep the same protocol as the original request. It can change the host, path, or query within that constraint, while normal browser security and networking rules still apply.
Q: Do all overrides survive redirects?
No. Header overrides apply to the routed request and redirects that it initiates. URL, method, and postData overrides apply only to the original request, so redirect scenarios need deliberate assertions.
Q: How should a test set a cookie for an overridden request?
Use browserContext.addCookies() before the request. Do not depend on changing the Cookie header in route.continue() because the browser can ignore forbidden request-header overrides.
Q: How do you prove an override worked?
Create a waitForRequest or waitForResponse promise before the triggering action, inspect the request properties, and assert the user-visible result. For critical transformations, unit test a pure transformation function as well.
Q: What happens if a route handler never calls a route action?
The intercepted request remains paused. Navigation or UI work that depends on it eventually times out, which can look like an application failure even though the route handler caused the problem.
Common Mistakes
- Registering the route after clicking or navigating, so the target request wins the race.
- Using
**/*for an endpoint-specific scenario and accidentally modifying images, scripts, analytics, or unrelated APIs. - Passing only a custom header and unintentionally removing required original headers.
- Trying to override
Cookie,Host, orContent-Lengthinstead of using supported browser and context APIs. - Expecting
continue()to behave like middleware and invoke the next matching handler. - Changing a method without aligning the body and content type with the server contract.
- Forgetting that URL, method, and body changes do not automatically carry through redirects.
- Calling two terminal actions on one route or leaving a conditional branch unresolved.
- Asserting only that the test did not throw, without checking the outgoing request or UI effect.
- Sharing mutable handler state across parallel workers, creating order-dependent failures.
Conclusion
Reliable playwright route continue with override examples are narrow, observable, and honest about using the real network. Register the route first, preserve unmodified fields, change only supported properties, and pair request-level evidence with a user-facing assertion.
Use continue() for a terminal request mutation, fallback() for composable handlers, and fetch() plus fulfill() when the response is what must change. Start with one endpoint and one override, then extract a typed helper only after the behavior is proven.
Interview Questions and Answers
Explain route.continue with overrides in Playwright.
A route handler pauses a matching browser request. route.continue() releases it to the network, optionally replacing url, method, postData, or headers. It is terminal for the routing chain, so other matching handlers do not run. I keep the matcher narrow and assert the outgoing request plus the UI result.
How would you safely add a test header to a request?
I copy route.request().headers(), add the lowercase custom key, and pass the complete object to route.continue({ headers }). This avoids dropping authorization or negotiation headers. I use browserContext.addCookies() for cookie state instead of forcing a Cookie header.
What is the difference between route.continue and route.fallback?
Both can pass request overrides. continue sends the request to the network immediately and skips other matching handlers. fallback passes control to the next matching handler, which is useful for layered policies such as shared headers plus endpoint-specific mocks.
How do request overrides behave across redirects?
Header overrides apply to the original routed request and redirects it initiates. URL, method, and postData changes apply only to the original request. I test redirect-sensitive behavior with explicit request or response predicates for each important hop.
How would you verify a postData override?
I create a page.waitForRequest() promise before triggering the action, match the exact path and method, and assert postDataJSON() on the captured request. I also check the response or visible application state so the test proves the changed body affected behavior.
When would you use route.fetch instead of route.continue?
I use route.fetch() when the handler needs the real server response before deciding what the browser should receive. After fetching, I call route.fulfill({ response, ...overrides }). continue is appropriate when only the outbound request needs modification.
Why can a broad route matcher make tests flaky?
A pattern such as **/* can intercept assets, analytics, and unrelated API calls. Conditional logic then becomes complex, and an unresolved branch can stall the page. I prefer endpoint-specific globs and method checks with an explicit action in every branch.
How do you prevent route state from leaking between tests?
I rely on Playwright Test's isolated page and context fixtures whenever possible. For a reused page, I retain the handler reference and remove it with page.unroute(). Scenario data stays inside the test closure so parallel workers do not share mutable values.
Frequently Asked Questions
What can Playwright route continue override?
It can override the request URL, HTTP method, post data, and headers. The URL must keep the original protocol, and browser restrictions can prevent some forbidden headers from being changed.
Does route.continue still call the real API?
Yes. It sends the original or modified request to the real network. Use route.fulfill() when you want to supply a response without calling the backend.
How do I remove a request header in a Playwright route?
Copy request.headers(), delete the lowercase key from the copy, and pass that object to route.continue({ headers }). Preserve the remaining headers unless the test intentionally replaces them.
Why is my second Playwright route handler not running?
A previous matching handler probably called route.continue(), route.fulfill(), or route.abort(). Use route.fallback() when processing must continue through another matching handler.
Can route.continue modify a JSON request body?
Yes. Read the original data with request.postDataJSON() when appropriate, create the desired serializable object, and pass it as postData. Set an appropriate content-type header and let the browser calculate content length.
Do Playwright request overrides apply to redirects?
Header overrides apply to the routed request and redirects it initiates. URL, method, and postData overrides apply only to the original request.
Should I use page.route or browserContext.route?
Use page.route() for one page and browserContext.route() when the rule must cover every page in the context, including popups. In both cases, register the route before the request can start.
Related Guides
- How to Use Playwright route continue with override (2026)
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright route abort: Examples and Best Practices
- Playwright route fulfill: Examples and Best Practices