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)
importasyncio, gcfromplaywright.async_apiimportasync_playwrightN=24PAGE="data:text/html,<html><body>"+"".join(
f'<img src="http://x-{i}.example/i.png">'foriinrange(80)
) +"</body></html>"# 80 sub-resource requests per page, no network neededdefdone_tasks():
gc.collect()
returnsum(1foroingc.get_objects() ifisinstance(o, asyncio.Task) ando.done())
asyncdefloop(browser, with_route):
for_inrange(N):
ctx=awaitbrowser.new_context()
ifwith_route:
asyncdefh(route):
try: awaitroute.abort()
exceptException: passawaitctx.route("**/*", h)
page=awaitctx.new_page()
try: awaitpage.goto(PAGE, wait_until="load", timeout=8000)
exceptException: passawaitpage.close()
awaitctx.close()
asyncdefmain():
asyncwithasync_playwright() aspw:
b=awaitpw.chromium.launch(headless=True, args=["--no-sandbox"])
awaitloop(b, with_route=True)
awaitb.close() # browser.close() does NOT free themprint("WITH route, after browser.close():", done_tasks())
b2=awaitpw.chromium.launch(headless=True, args=["--no-sandbox"])
awaitloop(b2, with_route=False) # controlawaitb2.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=awaitpage.context.new_cdp_session(page)
awaitcdp.send("Network.enable")
awaitcdp.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).