Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Intercepting and Modifying Network Requests

Mocking replaces a response wholesale, but many tests need the real server's data with a small change applied in flight: an auth header injected, a query parameter rewritten, a single field patched in the response, or an analytics beacon blocked entirely. Playwright's route() handler gives you three tools beyond fulfill()route.continue() with overrides, route.fetch() paired with route.fulfill(), and route.abort() — that let the request keep its connection to the live backend while you reshape exactly the bytes you care about. This page covers when to modify rather than mock, and how each technique maps to a concrete handler.

Three ways a route handler can modify traffic An intercepted request can be continued with overrides, fetched then fulfilled with edits, or aborted before it reaches the network. Page request route() continue(overrides) headers, method fetch() + fulfill() edit real body abort(reason) block beacon
Modification keeps the live connection: continue rewrites the outgoing request, fetch-then-fulfill rewrites the incoming response, abort cancels it.

Root cause: the response is right but one detail is wrong

Pure mocking solves non-determinism by inventing data, but it loses contact with the real backend, so it cannot prove the integration works and it goes stale silently. Many failures are narrower than that: the staging API needs a tenant header the test harness does not send, a third-party tracker slows the page and pollutes traces, or one volatile field (a timestamp, a generated id) breaks an otherwise valid assertion. For these you want the real round trip with a surgical edit, which is what route.continue(), route.fetch(), and route.abort() provide on top of Network Interception Basics, itself part of Advanced Interactions & Test Assertions.

Minimal reproducible example

The handler below lets the real /api/profile request go out, but injects an auth header on the way and patches the volatile lastSeen field on the way back so the assertion is stable.

import { test, expect } from '@playwright/test';

test('profile request is authenticated and response is normalized', async ({ page }) => {
  await page.route('**/api/profile', async (route) => {
    // 1. Fetch the REAL response, applying an outgoing header override.
    //    route.fetch() performs the network request the page would have made.
    const response = await route.fetch({
      headers: {
        ...route.request().headers(), // keep the browser's original headers
        'x-tenant': 'acme',            // inject the header the harness lacks
      },
    });

    // 2. Read the genuine JSON body returned by the backend.
    const body = await response.json();

    // 3. Patch only the volatile field; everything else stays real.
    body.lastSeen = '2026-06-19T00:00:00Z';

    // 4. Fulfill the page's request with the edited body but the real status.
    await route.fulfill({
      response,                       // reuse status + headers from the real response
      body: JSON.stringify(body),     // override only the body
    });
  });

  await page.goto('/account');

  // The UI shows real data, but the normalized timestamp makes this deterministic.
  await expect(page.getByText('Last seen: 2026-06-19')).toBeVisible();
});

Step-by-step fix

  1. Decide modify versus mock. If you need the live backend's data and only want to nudge one detail, modify. If you want to eliminate the dependency completely, mock with route.fulfill() per Mocking API Responses with Playwright instead.
  2. Rewrite the outgoing request with route.continue(). Pass { headers, method, postData, url } to change what leaves the browser while still hitting the real server. Spread route.request().headers() first so you only override the keys you mean to.
  3. Edit a real response with route.fetch() then route.fulfill(). Call route.fetch() to perform the genuine request, read its body, mutate the fields you need, then fulfill({ response, body }) so the status and headers stay authentic and only the body changes.
  4. Abort noise with route.abort(). Cancel analytics, ads, fonts, or third-party beacons by matching their URL and calling route.abort('blockedbyclient'). This speeds tests and keeps traces clean without touching application requests.
  5. Scope and order your handlers. Register more specific globs before broad ones; Playwright runs the most recently added matching handler first, and an unhandled route falls through to the network. Use context.route() to apply a rule to every page.
  6. Always settle the route. Each handler must call exactly one of continue, fulfill, fetch+fulfill, or abort. A handler that returns without settling hangs the request until it times out.

Troubleshooting variants

route.continue() with a new body has no effect

When you only need to change the request payload, pass postData to route.continue({ postData }); you cannot change the response from continue(). To alter the response you must switch to the route.fetch() + route.fulfill() pattern, because continue() hands control back to the network and never re-enters your handler. Confirm the rewrite landed by logging route.request().postData() for the matched request.

Modified response is rejected by the app as corrupt

Mismatched headers are the usual cause: if the real response was content-encoding: gzip and you replace the body with plain JSON, the browser tries to gunzip valid text and fails. When you override the body, drop or correct encoding and length headers — start from response for status but pass a clean contentType: 'application/json' and let Playwright recompute the length, rather than copying a stale content-length.

Aborting a request breaks an unrelated assertion

A glob that is too broad can abort an XHR the app needs to render. Narrow the pattern to the exact tracker host, and verify with page.on('requestfailed', r => console.log(r.url())) that only the intended URLs are cancelled. For traffic you want to silence but still observe in the Playwright Trace Viewer, prefer fulfilling an empty 204 over aborting so the request appears as handled rather than failed.

Verification

Prove each modification took effect. For header injection, run with --trace on and open the Network tab — the request row shows the x-tenant header you added. For response edits, assert on the patched field in the rendered UI and confirm the unedited fields still reflect live data, which a pure mock could not produce. For aborts, check page.on('requestfailed') fires for the blocked URL and never for an application endpoint, then rerun with the abort removed to confirm the page still works — that difference isolates exactly what your rule changed.

Frequently Asked Questions

When should I modify a request instead of mocking it?

Modify when you need the real backend's data and only want to change one detail — inject a header, rewrite a parameter, or normalize a volatile field. Mock when you want to remove the dependency entirely and control the whole payload. Modification keeps integration coverage; mocking trades it for total determinism.

Can route.continue() change the response body?

No. route.continue() only rewrites the outgoing request (url, method, headers, postData) and then lets the real server answer. To change the response you must call route.fetch() to get the real response, edit it, and pass it to route.fulfill().

Does route.abort() make the test fail?

Not by itself. route.abort() cancels the network request and surfaces it as a failed request, which is exactly what you want for trackers and ads. The test only fails if the application code actually depended on that request, so scope the glob tightly to third-party noise.

Back to overview