Environment
- Playwright (Python): 1.59.0 and 1.60.0 (both affected)
- Python: 3.14.4
- OS: Linux (Ubuntu 26.04 x64)
Summary
Registering a context.route(...) / page.route(...) handler causes the Playwright Python client
to retain references to the completed asyncio Tasks it spawns per intercepted request — both the
route-handler task and its underlying Connection.send task. These done tasks are not released
when the page, context, or browser is closed — only when the Playwright Connection closes (end of
async_playwright()). On a long-lived Connection scraping many pages, this grows memory roughly
linearly with the number of intercepted requests.
It is the interception, not the block/allow decision: a handler that continue_()s every request
leaks identically to one that abort()s every request.
Reproduction (plain Playwright, no extra deps)
import asyncio, gc
from playwright.async_api import async_playwright
N = 24
PAGE = "data:text/html,<html><body>" + "".join(
f'<img src="http://x-{i}.example/i.png">' for i in range(80)
) + "</body></html>" # 80 sub-resource requests per page, no network needed
def done_tasks():
gc.collect()
return sum(1 for o in gc.get_objects() if isinstance(o, asyncio.Task) and o.done())
async def loop(browser, with_route):
for _ in range(N):
ctx = await browser.new_context()
if with_route:
async def h(route):
try: await route.abort()
except Exception: pass
await ctx.route("**/*", h)
page = await ctx.new_page()
try: await page.goto(PAGE, wait_until="load", timeout=8000)
except Exception: pass
await page.close()
await ctx.close()
async def main():
async with async_playwright() as pw:
b = await pw.chromium.launch(headless=True, args=["--no-sandbox"])
await loop(b, with_route=True)
await b.close() # browser.close() does NOT free them
print("WITH route, after browser.close():", done_tasks())
b2 = await pw.chromium.launch(headless=True, args=["--no-sandbox"])
await loop(b2, with_route=False) # control
await b2.close()
print("NO route (control):", done_tasks())
asyncio.run(main())
Observed
WITH context.route("**/*"): done-task count climbs ~160 per context
(8 ctx -> 1281, 16 -> 2561, 24 -> 3841); NOT freed by browser.close()
NO route (control): 0 accumulation
A referrer trace shows the retained Channel.send / route-handler tasks held in a set reachable
from playwright._impl._transport.PipeTransport / _connection.Connection; they free only when the
Connection closes. asyncio's own registry is not the holder (asyncio.all_tasks() stays small).
Impact
A long scraping run on a single shared Connection (the normal pattern) grows Python RSS ~linearly
with intercepted requests and eventually OOMs. Observed in a real pipeline: ~3.5 MB/URL, 721 MB at
200 image-heavy pages.
Workaround (CDP-based blocking)
Block via CDP instead of context.route — no per-request Python handler, no task retention:
cdp = await page.context.new_cdp_session(page)
await cdp.send("Network.enable")
await cdp.send("Network.setBlockedURLs", {"urls": ["*.png*", "*.woff*", ...]})
Same reproduction with CDP blocking instead of context.route: 1 done task after 20 pages
(vs ~1600 with context.route).
Environment
Summary
Registering a
context.route(...)/page.route(...)handler causes the Playwright Python clientto retain references to the completed asyncio Tasks it spawns per intercepted request — both the
route-handler task and its underlying
Connection.sendtask. These done tasks are not releasedwhen the page, context, or browser is closed — only when the Playwright Connection closes (end of
async_playwright()). On a long-lived Connection scraping many pages, this grows memory roughlylinearly with the number of intercepted requests.
It is the interception, not the block/allow decision: a handler that
continue_()s every requestleaks identically to one that
abort()s every request.Reproduction (plain Playwright, no extra deps)
Observed
A referrer trace shows the retained
Channel.send/ route-handler tasks held in asetreachablefrom
playwright._impl._transport.PipeTransport/_connection.Connection; they free only when theConnection closes. asyncio's own registry is not the holder (
asyncio.all_tasks()stays small).Impact
A long scraping run on a single shared Connection (the normal pattern) grows Python RSS ~linearly
with intercepted requests and eventually OOMs. Observed in a real pipeline: ~3.5 MB/URL, 721 MB at
200 image-heavy pages.
Workaround (CDP-based blocking)
Block via CDP instead of
context.route— no per-request Python handler, no task retention:Same reproduction with CDP blocking instead of
context.route: 1 done task after 20 pages(vs ~1600 with
context.route).