Repository navigation
[Bug]: context.route retains completed Tasks until Connection close (memory leak) #3121
Description
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).
Activity
Skn0tt commentedon Jul 2, 2026
Fixed in Python via python/cpython#152585.