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 want | Method |
|---|---|
| Block images/fonts/ads | route.abort() |
| Pass request through unchanged | route.continue_() |
| Return fake data | route.fulfill() |
| Capture real response | route.fetch() then route.fulfill() |
| Add/change headers | route.continue_(headers=...) |
| One-off response capture | page.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.