Most people use Playwright to click things and read the DOM. But there’s a whole other layer available — you can intercept every network request the browser makes, and decide what happens to it.

Block ads. Swap API responses. Capture JSON data before it hits the page. Speed up scrapers by skipping images and fonts. All of this is built into Playwright with the route API.

Here’s how it works.


What is request interception?

When a browser loads a page, it sends dozens of requests — HTML, CSS, JavaScript, images, API calls, analytics pings. Normally all of these go through as-is.

With Playwright’s page.route(), you sit in the middle. You see every request, and you decide: let it through, block it, or change it.

Browser → [your route handler] → Server

This is useful for:

  • Scraping — capture API responses directly instead of parsing HTML
  • Testing — mock API responses so your test doesn’t depend on a live backend
  • Performance — block images, fonts, and tracking scripts to make pages load faster
  • Debugging — log every request your page is making

Requirements

  • Python 3.8+
  • Playwright installed
pip install playwright
playwright install chromium

Basic usage: intercepting requests

page.route() takes a URL pattern and a handler function. The pattern supports wildcards.

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        # Intercept all requests
        async def handle_request(route):
            print(f'Request: {route.request.method} {route.request.url}')
            await route.continue_()  # let it through

        await page.route('**/*', handle_request)
        await page.goto('https://example.com')
        await browser.close()

asyncio.run(main())

route.continue_() passes the request through unchanged. If you don’t call this (or one of the other response methods), the request hangs indefinitely.


Blocking requests

The most common use case. Block images, fonts, and analytics to speed up page loads significantly.

import asyncio
from playwright.async_api import async_playwright

BLOCKED_TYPES = {'image', 'font', 'media', 'stylesheet'}
BLOCKED_DOMAINS = {'google-analytics.com', 'googletagmanager.com', 'hotjar.com', 'facebook.net'}

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        async def block_resources(route):
            request = route.request
            resource_type = request.resource_type
            url = request.url

            # Block by resource type
            if resource_type in BLOCKED_TYPES:
                await route.abort()
                return

            # Block by domain
            if any(domain in url for domain in BLOCKED_DOMAINS):
                await route.abort()
                return

            await route.continue_()

        await page.route('**/*', block_resources)
        await page.goto('https://example.com')

        print(await page.title())
        await browser.close()

asyncio.run(main())

Blocking images and fonts alone cuts page load time by 30–60% on most sites. For scrapers that run thousands of pages, this adds up.


Capturing API responses

Here’s where it gets interesting for scraping. Many modern sites load their data via API calls — the HTML is almost empty and the content comes in as JSON. Instead of waiting for the DOM to render and then parsing it, you can capture the API response directly.

import asyncio
import json
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        captured_data = []

        async def capture_api(route):
            request = route.request

            # Only intercept API calls
            if '/api/' in request.url:
                # Let the request through, then capture the response
                response = await route.fetch()
                body = await response.json()
                captured_data.append({
                    'url': request.url,
                    'data': body
                })
                await route.fulfill(response=response)
                return

            await route.continue_()

        await page.route('**/*', capture_api)
        await page.goto('https://jsonplaceholder.typicode.com/posts/1')
        await page.wait_for_load_state('networkidle')

        print(json.dumps(captured_data, indent=2))
        await browser.close()

asyncio.run(main())

route.fetch() sends the request and gives you the response. route.fulfill(response=response) then forwards that response to the browser as normal. The page never knows you were in the middle.


Modifying requests

You can change the request before it goes out — swap headers, change the URL, or modify the POST body.

Adding or overriding headers

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        async def add_auth_header(route):
            headers = {
                **route.request.headers,
                'Authorization': 'Bearer your_token_here',
                'X-Custom-Header': 'my-value',
            }
            await route.continue_(headers=headers)

        await page.route('**/api/**', add_auth_header)
        await page.goto('https://example.com/dashboard')
        await browser.close()

asyncio.run(main())

Changing the request URL

async def redirect_request(route):
    url = route.request.url

    # Redirect staging to production
    if 'staging.example.com' in url:
        new_url = url.replace('staging.example.com', 'example.com')
        await route.continue_(url=new_url)
        return

    await route.continue_()

Mocking API responses

This is the most useful feature for testing. Instead of hitting a real API, you return a fake response. Your test runs faster, doesn’t depend on network conditions, and works offline.

import asyncio
import json
from playwright.async_api import async_playwright

MOCK_PRODUCTS = [
    {'id': 1, 'name': 'Widget A', 'price': 29.99},
    {'id': 2, 'name': 'Widget B', 'price': 49.99},
]

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        async def mock_api(route):
            if '/api/products' in route.request.url:
                await route.fulfill(
                    status=200,
                    content_type='application/json',
                    body=json.dumps(MOCK_PRODUCTS)
                )
                return

            await route.continue_()

        await page.route('**/*', mock_api)
        await page.goto('https://example.com/shop')

        # The page will receive your mock data instead of hitting the real API
        await browser.close()

asyncio.run(main())

You can also mock error states — return a 500 or a 404 — to test how your app handles failures.

async def mock_error(route):
    if '/api/user' in route.request.url:
        await route.fulfill(
            status=500,
            body='Internal Server Error'
        )
        return
    await route.continue_()

Modifying responses

You can fetch the real response and then change it before the browser sees it.

import asyncio
import json
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        async def modify_response(route):
            if '/api/config' in route.request.url:
                response = await route.fetch()
                data = await response.json()

                # Modify a field in the response
                data['feature_flags']['dark_mode'] = True

                await route.fulfill(
                    status=response.status,
                    headers=dict(response.headers),
                    body=json.dumps(data)
                )
                return

            await route.continue_()

        await page.route('**/*', modify_response)
        await page.goto('https://example.com')
        await browser.close()

asyncio.run(main())

Using expect_response for one-off captures

If you just need to capture a single response and move on, page.expect_response() is cleaner than setting up a persistent route.

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        # Wait for a specific API call that happens during navigation
        async with page.expect_response('**/api/user/profile') as response_info:
            await page.goto('https://example.com/dashboard')

        response = await response_info.value
        data = await response.json()
        print(data)

        await browser.close()

asyncio.run(main())

This is useful when a page triggers an API call during load and you want that data without setting up a full route handler.


Routing at the context level

If you want to intercept requests across all pages in a browser context (not just one page), use browser_context.route() instead.

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context()

        # This applies to every page opened in this context
        await context.route('**/*.{png,jpg,jpeg,gif,webp,svg}', lambda route: route.abort())

        page1 = await context.new_page()
        page2 = await context.new_page()

        await page1.goto('https://example.com')
        await page2.goto('https://example.org')

        await browser.close()

asyncio.run(main())

Common patterns and when to use them

What you wantMethod
Block images/fonts/adsroute.abort()
Pass request through unchangedroute.continue_()
Return fake dataroute.fulfill()
Capture real responseroute.fetch() then route.fulfill()
Add/change headersroute.continue_(headers=...)
One-off response capturepage.expect_response()

A real scraping example

Here’s what this looks like in practice — scraping a site that loads products via an API, while blocking all unnecessary resources:

import asyncio
import json
from playwright.async_api import async_playwright

async def scrape_products(url: str) -> list:
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        products = []

        async def handle_route(route):
            request = route.request

            # Block resources we don't need
            if request.resource_type in {'image', 'font', 'stylesheet', 'media'}:
                await route.abort()
                return

            # Capture product API responses
            if '/api/products' in request.url:
                response = await route.fetch()
                try:
                    data = await response.json()
                    if isinstance(data, list):
                        products.extend(data)
                    elif 'items' in data:
                        products.extend(data['items'])
                except Exception:
                    pass
                await route.fulfill(response=response)
                return

            await route.continue_()

        await page.route('**/*', handle_route)
        await page.goto(url)
        await page.wait_for_load_state('networkidle')

        await browser.close()
        return products

products = asyncio.run(scrape_products('https://example.com/shop'))
print(f'Found {len(products)} products')
print(json.dumps(products[:3], indent=2))

This is faster and more reliable than scraping the rendered DOM. The data is clean JSON, not strings you have to parse out of HTML.


Common issues

Route handler called but request still fails You’re not calling any of route.continue_(), route.fulfill(), route.abort(), or route.fetch(). Every handler must end with one of these, otherwise the request just hangs.

route.fetch() is slow It sends the request from Node.js (Playwright’s backend), not from the browser context. So cookies and browser-level headers may not be included automatically. Pass them explicitly if needed.

Route matches too broadly and breaks the page Start with specific URL patterns like **/api/** instead of **/*. If you block something the page needs to function, you’ll get errors that are hard to trace.