PRACTICAL GUIDE / Playwright fulfill response patching
Patch one upstream response without turning the test into a full mock
Use route.fetch and route.fulfill to change a live response safely, prove what came from upstream, and avoid masking service workers or contract drift.
In this guide6 sections
- Understand the two requests inside one route handler
- Patch a live JSON response while keeping the real contract
- Change request context, response headers, and one attempt on purpose
- Prove which layer produced the bad response
- Introduce patching without becoming the compatibility layer
- Leave the response untouched when fidelity is the requirement
What you will learn
- Understand the two requests inside one route handler
- Patch a live JSON response while keeping the real contract
- Change request context, response headers, and one attempt on purpose
- Prove which layer produced the bad response
The product page works against staging, except the response omits a field that the UI team has already shipped against. Replacing the whole endpoint with a fixture would hide authentication, redirects, headers, and the rest of the payload. Letting the request pass unchanged would make the scenario impossible to exercise. The useful compromise is to fetch the real response, change the one thing the test owns, and fulfill the paused browser request with that patched result.
This technique is precise when the test records what upstream actually returned. It becomes dangerous when a broad route quietly repairs every malformed response. A senior reviewer should be able to see the original status, the transformation, and the UI claim in the same test.
Understand the two requests inside one route handler
page.route() pauses every matching browser request before it goes to the network. The handler must resolve that paused route by calling route.continue(), route.fallback(), route.fulfill(), or route.abort(). If it returns without one of those actions, the browser request remains stalled and the page eventually times out.
route.continue() is the pass-through choice. It can change request details, but once called, the browser owns the request and the handler does not receive the response body. That is why it cannot support response patching.
route.fetch() takes a different path. It performs the route's request and gives the handler an APIResponse. The original browser request is still paused. The handler can inspect status, headers, and body, then use route.fulfill() to provide the browser with a response. Passing the APIResponse in the response option copies its response fields. A status, header collection, body, or JSON value supplied alongside it overrides the corresponding field.
The minimal shape looks like this:
await page.route('**/api/profile', async route => {
const upstream = await route.fetch();
try {
const profile = await upstream.json();
profile.canUseNewCheckout = true;
await route.fulfill({
response: upstream,
json: profile,
});
} finally {
await upstream.dispose();
}
});A reusable patch can keep the same safe ordering while refusing to repair non-JSON responses. The returned registration can be owned by the caller for explicit cleanup.
import type { Page } from '@playwright/test';
export async function patchCheckoutEligibility(page: Page) {
return page.route('**/api/profile', async route => {
const upstream = await route.fetch({ timeout: 10_000 });
try {
if (upstream.status() !== 200) {
await route.fulfill({ response: upstream });
return;
}
const contentType = upstream.headers()['content-type'] ?? '';
if (!contentType.includes('application/json')) {
await route.fulfill({ response: upstream });
return;
}
const value: unknown = await upstream.json();
if (!value || typeof value !== 'object')
throw new Error('Upstream profile was not a JSON object');
await route.fulfill({
response: upstream,
json: {
...(value as Record<string, unknown>),
canUseNewCheckout: true,
},
});
} finally {
await upstream.dispose();
}
});
}Disposing after route.fulfill() releases the APIResponse body rather than retaining it until the request context closes. The fulfill call must finish first because it may still need data from that response. This matters in a suite that patches hundreds of large payloads in one worker.
The request made by route.fetch() starts from the route's request. Options can override its headers, method, post data, URL, timeout, redirect limit, or network retry limit. Header overrides supplied to both route.fetch() and route.continue() carry into redirects, which matters when an authorization header was meant only for the first host. For both methods, url, method, and postData overrides apply only to the original request and do not carry over to redirected requests.
A fetched 404, 429, or 503 is not a thrown network error merely because the application considers it a failure. It is an APIResponse with that status. maxRetries does not turn route.fetch() into an HTTP retry policy. The option currently retries ECONNRESET-style network failures, not response codes. If the product is supposed to retry a 503, return the 503 to the browser and observe the product's retry.
Redirects create another decision. route.fetch() follows them up to its configured limit unless told otherwise. The returned APIResponse represents the final response. If the test needs to inspect the original 302 and Location header, set maxRedirects to 0 and handle that response deliberately. Otherwise, a patch may be applied after an unexpected cross-origin redirect and conceal the changed architecture.
This self-contained test proves that the handler observes the first 302 and passes it to the browser unchanged instead of patching the final JSON response:
import { createServer } from 'node:http';
import { once } from 'node:events';
import { expect, test } from '@playwright/test';
test('passes an upstream redirect through without patching it', async ({
page,
}) => {
const server = createServer((request, response) => {
if (request.url === '/') {
response.writeHead(200, { 'content-type': 'text/html' });
response.end([
'<p id="result">loading</p>',
'<script>',
'fetch("/api/profile")',
' .then(response => response.json())',
' .then(profile => {',
' document.querySelector("#result").textContent = profile.state;',
' });',
'</script>',
].join('\n'));
return;
}
if (request.url === '/api/profile') {
response.writeHead(302, { location: '/session' });
response.end();
return;
}
if (request.url === '/session') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ state: 'sign-in-required' }));
return;
}
response.writeHead(404).end();
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Expected a TCP listener');
const origin = 'http://127.0.0.1:' + address.port;
let upstreamStatus: number | undefined;
let upstreamLocation: string | undefined;
try {
await page.route(origin + '/api/profile', async route => {
const upstream = await route.fetch({ maxRedirects: 0 });
try {
upstreamStatus = upstream.status();
upstreamLocation = upstream.headers().location;
await route.fulfill({ response: upstream });
} finally {
await upstream.dispose();
}
});
await page.goto(origin);
await expect(page.locator('#result')).toHaveText('sign-in-required');
expect(upstreamStatus).toBe(302);
expect(upstreamLocation).toBe('/session');
} finally {
server.close();
await once(server, 'close');
}
});Routing also disables the browser's HTTP cache. A test using page.route() therefore does not measure the same cache behavior as an unmodified production visit. That cost applies even if the handler eventually passes requests through. Keep the URL matcher narrow, and do not use response interception in a cache-performance test.
Patch a live JSON response while keeping the real contract
The following Playwright Test file is runnable without a separate application. A Node HTTP server represents the upstream service and serves a small page that fetches an order. The route changes only the feature decision. Authentication, URL, status, content type, and unrelated fields still come from the server.
import { createServer } from 'node:http';
import { once } from 'node:events';
import { test, expect } from '@playwright/test';
test('shows express controls for an eligible order', async ({ page }) => {
const server = createServer((request, response) => {
if (request.url === '/') {
response.writeHead(200, { 'content-type': 'text/html' });
response.end([
'<button id="load">Load order</button>',
'<p id="result"></p>',
'<script>',
'document.querySelector("#load").addEventListener("click", async () => {',
' const response = await fetch("/api/orders/42");',
' const order = await response.json();',
' document.querySelector("#result").textContent =',
' order.expressEligible ? "Express available" : "Standard only";',
'});',
'</script>',
].join('\n'));
return;
}
if (request.url === '/api/orders/42') {
response.writeHead(200, {
'content-type': 'application/json',
'x-contract-version': '7',
});
response.end(JSON.stringify({
id: 42,
currency: 'INR',
total: 2499,
expressEligible: false,
}));
return;
}
response.writeHead(404).end();
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Expected a TCP listener');
const origin = 'http://127.0.0.1:' + address.port;
let observedUpstream: unknown;
try {
await page.route(origin + '/api/orders/42', async route => {
const upstream = await route.fetch();
try {
expect(upstream.status()).toBe(200);
expect(upstream.headers()['x-contract-version']).toBe('7');
const order = await upstream.json();
observedUpstream = structuredClone(order);
expect(order).toEqual({
id: 42,
currency: 'INR',
total: 2499,
expressEligible: false,
});
await route.fulfill({
response: upstream,
json: {
...order,
expressEligible: true,
},
});
} finally {
await upstream.dispose();
}
});
await page.goto(origin);
await page.getByRole('button', { name: 'Load order' }).click();
await expect(page.locator('#result')).toHaveText('Express available');
expect(observedUpstream).toEqual({
id: 42,
currency: 'INR',
total: 2499,
expressEligible: false,
});
} finally {
server.close();
await once(server, 'close');
}
});Two assertions prevent this from becoming a repair script. First, the handler asserts the upstream contract version and complete baseline body. If staging starts returning currencyCode instead of currency, the test fails at the contract boundary rather than synthesizing a payload the product never received. Second, the final locator assertion proves the page consumed the changed field.
A full-object assertion is suitable for this tiny contract. On a large production payload, assert the fields that the patch relies on and validate its schema with the suite's existing contract tool. Avoid a loose cast such as const order = await upstream.json() as Order followed by a mutation. A TypeScript cast does not inspect runtime JSON. It can make a broken payload look safe to the compiler.
The handler stores a clone of the original data because it later constructs a new object. Mutating the same object and attaching it as evidence can confuse review: the "upstream" attachment may show the patched value. Preserve evidence before transformation.
The code passes both response and json to route.fulfill(). This keeps the original status and response headers while replacing the body through Playwright's JSON path. Building a response from status: 200 and body: JSON.stringify(...) would discard useful details unless every header were copied. Conversely, manually copying upstream headers while sending uncompressed text can preserve stale Content-Encoding or Content-Length values. Let the response-plus-json form handle the ordinary JSON case.
If the upstream body is not valid JSON, upstream.json() throws. That is good. Do not catch the parse error and substitute an empty object, because the test would turn an outage or HTML error page into a convincing application response.
Change request context, response headers, and one attempt on purpose
A second pattern adds a test-only tenant header to the upstream request and a diagnostic header to the response. The browser never sees the secret test credential, while the test can prove that the patched response came through its handler.
import { test, expect } from '@playwright/test';
test('loads the isolated QA tenant', async ({ page }) => {
let upstreamStatus: number | undefined;
await page.route('**/api/account', async route => {
const headers = await route.request().allHeaders();
const upstream = await route.fetch({
headers: {
...headers,
'x-test-tenant': 'qa-isolated',
},
timeout: 10_000,
maxRedirects: 0,
});
try {
upstreamStatus = upstream.status();
if (upstream.status() !== 200) {
await route.fulfill({ response: upstream });
return;
}
const account = await upstream.json();
const patchedHeaders: Record<string, string> = {
...upstream.headers(),
'cache-control': 'no-store',
'x-playwright-patch': 'qa-isolated-account',
};
delete patchedHeaders['content-encoding'];
delete patchedHeaders['content-length'];
await route.fulfill({
response: upstream,
headers: patchedHeaders,
json: {
...account,
supportChatEnabled: true,
},
});
} finally {
await upstream.dispose();
}
});
const responsePromise = page.waitForResponse(
response => response.url().endsWith('/api/account')
);
await page.goto('https://app.test.internal/account');
const response = await responsePromise;
expect(upstreamStatus).toBe(200);
expect(response.headers()['x-playwright-patch'])
.toBe('qa-isolated-account');
await expect(page.getByRole('button', { name: 'Chat with support' }))
.toBeVisible();
});This example is runnable in an environment where app.test.internal hosts the test deployment. The exact request and response APIs are real; the environment-specific URL is intentionally explicit.
There are two costs. The handler now knows a test-only upstream credential convention, and setting maxRedirects to 0 means a legitimate redirect reaches the browser rather than being patched. That is useful if cross-origin redirects must remain visible. It also means the route may not produce JSON on every run, so the status branch passes unexpected responses through unchanged.
Do not log authorization or cookie headers while diagnosing this flow. route.request().allHeaders() can include sensitive values. Attach an allowlisted subset such as Accept, Content-Type, and a generated correlation ID. The value sent as x-test-tenant should be a non-secret routing label. Real secrets belong in server-side test infrastructure, not in evidence.
One-shot failure injection serves a different need. Suppose the page retries a stock request after a 503, but the response should otherwise remain genuine. Register a handler with times: 1, fetch the first real response to prove the dependency was healthy, then replace only that first response.
import { test, expect } from '@playwright/test';
test('retries inventory after one service failure', async ({ page }) => {
let originalStatus: number | undefined;
await page.route('**/api/inventory/SKU-104', async route => {
const upstream = await route.fetch();
try {
originalStatus = upstream.status();
expect(originalStatus).toBe(200);
const failureHeaders: Record<string, string> = {
...upstream.headers(),
'content-type': 'application/json',
'retry-after': '1',
'x-failure-source': 'playwright',
};
delete failureHeaders['content-encoding'];
delete failureHeaders['content-length'];
await route.fulfill({
response: upstream,
status: 503,
headers: failureHeaders,
json: {
code: 'INVENTORY_TEMPORARILY_UNAVAILABLE',
},
});
} finally {
await upstream.dispose();
}
}, { times: 1 });
const responses: number[] = [];
page.on('response', response => {
if (response.url().endsWith('/api/inventory/SKU-104'))
responses.push(response.status());
});
await page.goto('https://shop.test.internal/products/SKU-104');
await expect(page.getByText('12 in stock')).toBeVisible({
timeout: 15_000,
});
expect(originalStatus).toBe(200);
expect(responses).toEqual([503, 200]);
});The expected [503, 200] sequence proves the application retried and the route expired after one use. A final UI assertion alone is weaker. It could pass because no failure was injected, because a service worker served cached inventory, or because the page showed stale stock.
The latency trade-off is concrete. This test waits for a genuine first upstream response, throws it away, then waits for the product's retry and another upstream response. It performs at least two network calls and includes the configured backoff. A full mock could finish much faster. Keep this scenario in a focused resilience group instead of multiplying it across every browser and locale.
The original 200 is also a deliberate precondition. If upstream is already returning 503, the test fails before injecting its own 503. That separates a staging incident from the product behavior under controlled failure.
Prove which layer produced the bad response
Start with the trace, not a larger timeout. Run the focused test with network evidence retained:
npx playwright test tests/inventory-retry.spec.ts --trace=on
npx playwright show-trace test-results/inventory-retry-*/trace.zipTrace Viewer's Network tab marks routed requests. Inspect the request URL, method, status, response headers, and timing. The x-failure-source or x-playwright-patch header from the examples identifies the fulfilled response. Do not add that header if exposing test internals to the page creates product behavior; in that case, store the marker in test variables and an attachment.
Recent Playwright versions do not show route.fulfill() as a separate action in the trace action list. Looking only at actions can therefore produce the mistaken claim that the handler never ran. The network entry and explicit test evidence are the better sources.
A useful structured diagnostic has no bodies or secrets:
route_patch={
"request":"GET /api/inventory/SKU-104",
"upstreamStatus":200,
"fulfilledStatus":503,
"handlerUse":1,
"correlationId":"retry-7d52"
}Emit it once per matching request or attach it to the test result. Avoid console spam from a catch-all route. High-volume logs hide the first unexpected request and can inflate CI artifacts.
Several near-misses look identical from the final assertion:
- A service worker handled the API call, so page.route() never saw it. Playwright documents that page routes do not intercept service worker requests. Create a diagnostic context with serviceWorkers: 'block' and rerun. If the handler count changes from zero to one, the worker is part of the cause.
- The URL pattern missed a query string, version prefix, or hostname. Record page.on('request') URLs temporarily and compare the exact method and URL. Do not broaden the route to */ in the committed test.
- A popup issued its first document request before its page-level route was installed. Browser-context routing can catch that initial request. Use it only if the route should apply to every page in that context.
- CORS rejected the fulfilled response. The network entry can show 200 while the page console reports a blocked cross-origin read. Check Access-Control-Allow-Origin and the page's origin. Reachability and browser permission are separate.
- The application never consumed the endpoint. A handler count of zero plus no matching network request means the product took another branch. Fix test setup or the product expectation, not the route.
A handler that starts but never resolves produces a different signature. page.goto() or a locator waits until timeout, the network request remains pending, and no response status appears. Review every branch. An early return after route.fetch() without route.fulfill() is enough to stall the page.
An upstream hang occurs inside route.fetch(), so give it a bounded timeout. The error points into route.fetch rather than the final locator. Record that distinction. If the handler catches the timeout and calls route.abort(), the browser sees a network failure, which may be exactly the resilience scenario. If the test is about ordinary response patching, let the exception fail the test and expose the infrastructure problem.
Response decompression can create another false diagnosis. route.fetch().json() gives parsed JSON even when the server compressed the wire body. If code later fulfills a plain string while manually retaining Content-Encoding: gzip, the browser attempts to decode bytes that are not gzipped. Prefer the response-plus-json form. When hand-building a body is unavoidable, construct matching representation headers instead of copying all upstream headers blindly.
Introduce patching without becoming the compatibility layer
Begin rollout with one endpoint and one named transformation. Put the route beside the test that needs it, not in a global beforeEach. A global response repair accumulates exceptions until no one knows which contract the browser actually receives.
Extract a helper only after two tests share the same intent. Its interface should state the URL and transformation, and it should expose the original response to assertions. A helper that accepts any glob and any callback is merely page.route() with less visibility.
Add precondition checks before the mutation. Validate the upstream status, content type, schema version, and fields the transformation reads. Fail with a message that names upstream evidence:
function assertPatchableAccount(value: unknown): asserts value is {
id: string;
supportChatEnabled: boolean;
} {
if (!value || typeof value !== 'object')
throw new Error('Upstream account body was not an object');
const account = value as Record<string, unknown>;
if (typeof account.id !== 'string')
throw new Error('Upstream account body did not contain a string id');
if (typeof account.supportChatEnabled !== 'boolean') {
throw new Error(
'Upstream account body did not contain supportChatEnabled'
);
}
}This code performs runtime checks, unlike a cast. A mature suite can use its established schema validator, but the principle stays the same: the patch is allowed only on a contract the test recognizes.
Track why the patch exists. Link it to a feature flag, defect, or environment limitation in the test annotation system used by the team. Give temporary patches a removal condition. Otherwise, the test may keep forcing supportChatEnabled to true months after staging supplies the field correctly, and genuine rollout bugs will remain invisible.
Run a paired baseline. One test leaves the response unchanged and proves the ordinary flow. Another applies the patch for the special branch. If only the patched test exists, upstream drift can be hard to distinguish from transformation logic. The baseline costs another request and more CI time, so keep it at the endpoint or contract level rather than duplicating the whole UI journey.
During migration, count matches. Expect exactly one call when the scenario assumes one. Modern applications prefetch, retry, and revalidate; a broad handler might patch three requests. Whether that is correct depends on the product. An explicit count turns an invisible change in request behavior into a reviewable failure.
Parallel workers do not share page routes, but they may share the upstream account or feature state. Response patching is attractive because it changes only what one browser sees and avoids server-side mutation. The cost is that it no longer proves the real server can produce that state. Keep at least one API or integration test for the server-side state transition.
Review memory and payload size. route.fetch() plus parsing and reserialization buffers the response. Ten workers patching 80 MB exports can consume gigabytes and delay garbage collection. Restrict body transformations to bounded payloads, dispose APIResponse objects, and measure worker memory before scaling the pattern.
Leave the response untouched when fidelity is the requirement
Do not patch responses in a contract test. Its job is to detect what the service actually returned. A transformation between service and assertion defeats that purpose, even if the helper claims to normalize harmless fields.
Avoid it in performance, cache, compression, streaming, range-request, and download tests. Routing disables HTTP cache, and body modification changes byte size, encoding, timing, and often buffering. Server-sent events and long-lived streams are especially poor candidates for fetch-then-fulfill because there may be no complete body to patch at the moment the page needs data.
A security test should not silently repair CORS, CSP, authentication, or authorization headers. If the purpose is to demonstrate the browser rejects an unsafe response, pass the real response through. Changing headers to make the UI work tests a different policy.
Use a full mock when the upstream system is deliberately absent, costly, destructive, or too unstable for the scenario. A payment decline fixture should not call a live processor and then rewrite approval into decline. Model the complete response with an owned mock and separately test the integration boundary.
Use server-side test data when the product behavior depends on relationships across multiple endpoints. Patching GET /account while POST /checkout and a WebSocket still see the old state creates an impossible world. Seeding the feature or account in the service costs setup time but keeps all channels coherent.
Do not patch every response to smooth over a staged backend rollout. If the frontend must support both contract versions, write one case for each unmodified version using controlled fixtures or environments. A universal normalizer in Playwright can make an incompatible deployment appear healthy.
Finally, prefer observation when the only goal is diagnostics. page.waitForResponse(), page.on('response'), and the trace network view can inspect status and headers without interception. Adding page.route() changes cache behavior and creates a handler that can stall requests. A read-only question deserves a read-only tool.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
How do I change one field in a real API response with Playwright?
Fetch the original response inside a page.route() handler, parse its JSON, change the required field, then call route.fulfill() with both the original response and the replacement JSON. Passing the response preserves its status and headers unless a fulfill option overrides them.
What is the difference between route.fetch and route.continue?
route.continue() sends the browser request onward and ends interception, so the handler cannot edit the returned body. route.fetch() performs the request and returns an APIResponse to the handler, leaving the route paused until the handler fulfills, continues, or aborts it.
Why is my Playwright route handler never called?
Service workers can intercept requests before page.route() sees them, and popup pages can issue their first request before a page-level route exists. Check the trace network entry, narrow the URL carefully, and use serviceWorkers: 'block' or a browser-context route when those cases match the product flow.
Does route.fetch retry a 500 response?
HTTP error statuses are still valid responses and are not retried by route.fetch(). Its maxRetries option covers specific network failures such as ECONNRESET, so application-level retry behavior should be tested in the page or modeled explicitly.
When should I use a full mock instead of patching upstream?
Choose a full mock when the scenario requires exact, repeatable fixtures or when the real dependency is unavailable in CI. Response patching is better when most upstream behavior must remain real and the test deliberately changes only a small, named part of the contract.
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright existingResponse Route Request Testing
Learn Playwright request existingResponse route testing with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.
GUIDE 02
Assert API TLS Security Details with Playwright
Learn Playwright API response TLS security details with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.
GUIDE 03
Debug Missing Playwright Route Events Behind Service Workers and Caches
Trace missing Playwright route events through page, service worker, and cache ownership, then choose deterministic interception or worker-aware assertions.
GUIDE 04
How to Take Screenshots in Playwright
Learn how to take screenshots in Playwright for full page, element, and failure captures, plus visual checks and practical CI debugging tips.
GUIDE 05
How to Run Tests in Parallel with Playwright
Learn how to run tests in parallel with Playwright: workers, sharding, isolation, CI config, flakiness fixes, and a practical checklist for faster suites.