Skip to content

Blocking and Mocking Requests in Playwright

A rendered page fetches far more than the content you want: images, stylesheets, fonts, analytics, ads. Playwright's request routing lets automation code block those, rewrite requests, or answer them with mock data — useful for speed, for privacy, and for testing pages against controlled API responses. Each routed request, though, travels from the browser through the Playwright driver to a Python handler and back, so routing has a cost of its own. Measured on Python 3.14 with Playwright 1.63 on a test page that loaded 22 resources, 30 pages per variant, best of three runs: time to the page's load event was 241 ms with no routing; 108 ms when a route aborted the 21 images and stylesheet; and 313 ms when a catch-all route simply continued every request — about 3 ms of overhead per intercepted request. Blocking did not help when the goal was a JavaScript-rendered price, because the images were never on that path. This guide routes only what pays.

Prerequisites

1. Block resource types you do not need

Register a route with a glob pattern before navigating; matching requests go to the handler, which can abort them:

async def fetch_page(context, url: str) -> str:
    page = await context.new_page()
    await page.route("**/*.{png,jpg,jpeg,gif,webp,css,woff,woff2}", lambda route: route.abort())
    await page.goto(url, wait_until="load")
    return await page.content()

Measured: the load event — which waits for images and stylesheets — arrived after 108 ms instead of 241 ms, more than twice as fast, because 21 of the page's 22 requests never left the browser. Routes can also be set on the context (context.route(...)), applying to every page in it, which is the better place for a blocking policy shared by all jobs. Aborted resources do not reach the server at all, which also reduces load on the site being automated.

Verify: count requests per page (page.on("request", ...)) and bytes received before and after adding the route.

Time to the load event, 22-resource page 3 horizontal bars comparing no routing with the others. Time to the load event, 22-resource page no routing 241 ms route aborts png/css 108 ms route continues everything 313 ms Playwright 1.63, Python 3.14; 30 pages per variant, best of 3 runs on a shared machine. Blocking saves the blocked requests; routing everything costs about 3 ms each.

2. Do not route requests you will only continue

A catch-all route that inspects every request and continues most of them is a common pattern for logging or selective modification. Every matched request then waits for the Python handler:

# Costs ~3 ms per request on this machine, for no change in behaviour
await page.route("**/*", lambda route: route.continue_())

# Observe without intercepting: events do not block the request
page.on("request", lambda req: log.debug("request %s %s", req.method, req.url))
page.on("response", lambda resp: STATUS.labels(code=resp.status).inc())

Measured: 313 ms to load with the continue-everything route, 72 ms more than with no routing, for a page of 22 requests. Under concurrency the handlers also compete for the event loop with everything else in the process. Use the narrowest glob or predicate that matches the requests you actually change, and use the request, response and requestfailed events — which notify without holding the request — for observation.

Verify: each route's pattern matches only requests it aborts, fulfils or modifies.

3. Know when blocking does not help

Blocking speeds up what waits for the blocked resources. On the test page, the price was rendered by a script after a timer and an API call; images loaded in parallel and were not on that path:

await page.route("**/*.{png,css}", lambda r: r.abort())
await page.goto(url)                                   # default wait_until="load"
await page.locator("#price[data-ready]").wait_for()    # the timer + API call decide this

Measured with a locator wait as the goal, blocking made no improvement — 538 ms per page with blocking against 380 ms without in one run, and comparable figures in others, within the noise of a shared machine plus routing overhead. Before adding routes, identify what the readiness condition waits for: block resources on that path (large images in a layout-shifting page, render-blocking stylesheets, slow third-party scripts) and leave the rest. Third-party scripts are often the best candidates, since they can delay the load event and the page's own scripts without contributing content.

Verify: compare time to your actual readiness condition with and without the route, not just time to load.

Should this request be routed? A decision on What do you want to do with the request with 4 outcomes. Should this request be routed? What do you want to do with the request? never need it route + abort, narrow glob 241 -> 108 ms to load just observe it page.on('request'/'response') no per-request cost control the data (tests) route + fulfill(json=...) deterministic pages only continue it do not route ~3 ms each otherwise Route narrowly; observe with events.

4. Mock API responses for deterministic runs

route.fulfill answers a request without the network. For testing pages, or for automations that should not touch production APIs, mock the data the page fetches:

import json


async def with_mock_prices(page, prices: dict[int, str]) -> None:
    async def handle(route):
        product_id = int(route.request.url.rsplit("/", 1)[-1])
        await route.fulfill(status=200, content_type="application/json",
                            body=json.dumps({"price": prices.get(product_id, "0.00")}))

    await page.route("**/api/price/*", handle)


await with_mock_prices(page, {7: "99.00"})
await page.goto("https://shop.example/p/7")
await expect(page.locator("#price")).to_have_text("99.00")

Mocked responses make page behaviour independent of backend state and latency, so tests that render real front-end code become fast and repeatable. Fulfil errors too — status=500, slow responses via await asyncio.sleep in the handler — to test how the page degrades. Playwright can also record real traffic into a HAR file and replay it (context.route_from_har(...)), which is useful when many endpoints must be mocked at once; the same idea for pure HTTP clients is in mocking httpx calls in async tests with respx.

Verify: the mocked page renders the mocked value, and the real API receives no requests during the run.

5. Modify requests and responses sparingly

Routes can also rewrite: add headers, change URLs, or fetch the real response and alter it before the page sees it:

async def add_auth(route):
    headers = {**route.request.headers, "authorization": f"Bearer {TOKEN}"}
    await route.continue_(headers=headers)

await context.route("**/api/**", add_auth)


async def strip_tracking(route):
    response = await route.fetch()                         # the real response
    html = (await response.text()).replace(TRACKING_SNIPPET, "")
    await route.fulfill(response=response, body=html)

await page.route("**/article/*", strip_tracking)

Each of these adds the per-request routing cost plus whatever the handler does, and route.fetch() moves the response body through Python. Keep them to the few requests that need them. Credentials injected through routes are visible to the page's scripts once the response arrives, so scope them to the hosts that need them.

The path of one routed request A sequence of 6 messages between 4 participants. The path of one routed request page Chromium driver Python handler request /img/1.png paused: route matched route event over the pipe abort / continue_ / fulfill decision proceed (or fail fast) Every matched request makes this round trip; measured at about 3 ms each.

Verify: modified requests carry the intended changes (checked in server logs or a mock server), and unmodified traffic is not routed.

Verification

Request routing is used well when:

  • Unneeded resources are aborted with a narrow pattern, measured against your real readiness condition.
  • Observation uses events, not catch-all routes.
  • API mocks make test runs deterministic, including error and slow responses.
  • Rewriting routes cover only the requests they change.

Diagnostic Hook: record intercepted requests per page alongside time to readiness. Intercepted counts close to total requests point at a catch-all route; a readiness time that did not move after blocking means the blocked resources were never on the critical path.

Pitfalls & edge cases

  • Catch-all routes that continue. Measured: about 3 ms added per request.
  • Blocking off the critical path. Measured: no gain for a script-rendered value.
  • Forgetting context-level routes. Per-page routes must be re-added for every page.
  • Blocking CSS when layout matters. Element visibility checks depend on styles.

Frequently Asked Questions

How do I block images in Playwright Python?

Register a route before navigating: await page.route('*/.{png,jpg,gif,webp}', lambda r: r.abort()), or on the context to cover every page. In testing, aborting images and CSS cut time to the load event from 241 to 108 ms.

Does page.route slow Playwright down?

Every matched request goes through the driver to a Python handler; a catch-all route that only continued requests added about 3 ms per request in testing. Route narrowly and observe with page.on('request') instead.

How do I mock an API response in Playwright?

Route the API URL and call route.fulfill with status, content_type and body (or json) in the handler; the page receives the mocked response without a network request.

Why didn't blocking resources make my scraper faster?

The blocked resources were not on the path to your readiness condition. Measure time to the condition you actually wait for; blocking only helps resources that delay it.