Cleaning Up Playwright on Cancellation¶
Browser automation jobs get cancelled all the time: a timeout fires, a request that started the job is abandoned, the service shuts down. Each job holds browser resources — a context, its pages, their renderer memory — and whether those are released depends on how the job's code is written, not on Playwright. Measured on Python 3.14 with Playwright 1.63: twenty jobs were started and cancelled 150 ms later, mid-navigation. When each job called await context.close() as its last line, 6 contexts were still open a second after the cancellations — the jobs that had created a context before being cancelled never reached the close. When each job used async with await browser.new_context(), 0 were left. At the process level the picture was reassuring: killing the Python process with SIGTERM or even SIGKILL left no Chromium processes behind, because Playwright's driver shuts the browser down when its parent's pipes close. This guide makes every exit path release what a job opened.
Prerequisites¶
- Python 3.9+,
pip install playwrightandplaywright install chromium. - Cancellation semantics, from cancelling a task and waiting for it to finish.
- The topic overview, Browser Automation.
1. See the leak a trailing close() leaves¶
The tempting job structure creates, works, then closes:
async def visit(browser, url: str) -> str:
context = await browser.new_context()
page = await context.new_page()
await page.goto(url) # cancellation lands here...
await page.locator("#price[data-ready]").wait_for()
text = await page.locator("#price").text_content()
await context.close() # ...so this never runs
return text
Measured: of 20 jobs cancelled mid-navigation, 6 left their contexts open — len(browser.contexts) was 6 a second later. A CancelledError (or a TimeoutError from the navigation, or any exception) raised at an await skips every line after it. Each leaked context holds its pages and their memory, about 20 MiB apiece in the measurements in running Playwright with asyncio, until the browser closes — which in a long-running service may be never.
Verify: after cancelling a batch of jobs in a test, len(browser.contexts) returns to its starting value.
2. Scope every context with async with¶
BrowserContext is an async context manager; leaving the block closes it however the block is left:
async def visit(browser, url: str) -> str:
async with await browser.new_context() as context:
page = await context.new_page()
await page.goto(url)
await page.locator("#price[data-ready]").wait_for()
return await page.locator("#price").text_content()
Measured: 0 contexts left after the same 20 cancellations. Closing the context closes its pages too, so pages need no separate handling. The close itself is an awaited call to the browser; during cancellation it runs in the __aexit__, which asyncio allows, and the cancellation is re-raised afterwards. Where a structure prevents async with — a context opened in one method and used across several — use try/finally with the same effect, and keep the close in the finally as the first statement after the work.
Verify: a lint check or review rule flags new_context() calls outside async with or a try/finally.
3. Bound each job with a timeout that cancels cleanly¶
Playwright actions have their own timeouts, but a job is several actions; bound the whole job, so a page that keeps almost-working cannot hold a worker forever:
async def visit_bounded(browser, url: str, budget: float = 20.0) -> str | None:
try:
async with asyncio.timeout(budget):
return await visit(browser, url) # async with inside closes on timeout
except TimeoutError:
log.warning("gave up on %s after %.0fs", url, budget)
return None
When the deadline passes, asyncio.timeout cancels the job at its current await; the async with in visit closes the context during the unwinding; then the timeout converts the cancellation into TimeoutError. Context-level defaults (context.set_default_timeout) still bound individual actions inside the budget. The interaction between the two is the subject of choosing asyncio.timeout vs wait_for: either works, and asyncio.timeout reads better around a block.
Verify: a job against a page that never becomes ready returns None at the budget, and its context is closed.
4. Shut the whole pool down in order¶
At service shutdown, stop accepting jobs, cancel or drain the running ones, then close the browser and the driver — in that order, so every context's close has a browser to talk to:
async def run_service(urls: asyncio.Queue) -> None:
async with async_playwright() as p:
browser = await p.chromium.launch()
try:
async with asyncio.TaskGroup() as tg:
for _ in range(8):
tg.create_task(worker(browser, urls))
finally:
await browser.close() # after every worker's contexts are closed
# leaving async_playwright() stops the driver
When the service's main task is cancelled — by a SIGTERM handler, for example — the TaskGroup cancels its workers and waits for them; each worker's async with closes its context; only then does browser.close() run. Closing the browser first would make those context closes fail with Playwright's "Target page, context or browser has been closed" errors and turn an orderly shutdown into a stream of exceptions. The general shutdown sequence is in Graceful Shutdown & Signal Handling.
Verify: a SIGTERM during a busy run produces no "has been closed" errors in the logs, and the process exits within its shutdown budget.
5. Trust the driver for process exit, but verify in containers¶
Measured: after the Python process was terminated with SIGTERM — without running any cleanup — and separately with SIGKILL, none of its 7 child processes (the driver and Chromium's processes) were running three seconds later. The Playwright driver watches its connection to Python and closes the browser when it goes away. Containers add one wrinkle:
FROM python:3.14-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
RUN pip install playwright && playwright install --with-deps chromium
# An init process reaps exited children and forwards signals to Python
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "-m", "crawler"]
Chromium starts several child processes over its lifetime; in a container where Python is PID 1, orphaned descendants are re-parented to PID 1, and Python does not reap them, so exited ones can linger as zombies. An init such as tini (or docker run --init) handles reaping and signal forwarding. The PID 1 problem in general is covered in Containers & Serverless.
Verify: after a long run in the container, ps shows no <defunct> Chromium processes, and stopping the container leaves none behind.
Verification¶
Browser resources are released on every path when:
- Every context is opened with
async with(ortry/finally), never closed on a trailing line. - Each job has an overall timeout, and timed-out jobs leave no contexts open.
- Shutdown cancels workers before closing the browser, without "has been closed" errors.
- Containers run an init process, and no zombie or orphaned Chromium processes remain.
Diagnostic Hook: export len(browser.contexts) as a gauge sampled every few seconds. In a healthy service it tracks the number of jobs in flight and returns to zero when idle; a floor that rises after each burst of timeouts or cancellations is a context leak with a precise count.
Pitfalls & edge cases¶
await context.close()as the last line. Measured: 6 of 20 cancelled jobs leaked their contexts.- Closing the browser before the workers finish. Their context closes fail noisily.
- Python as PID 1 in a container. Chromium's exited children become zombies.
- No job-level timeout. Per-action timeouts do not bound a job of many actions.
Frequently Asked Questions¶
How do I clean up Playwright contexts when a task is cancelled?
Open each context with async with await browser.new_context() as ctx; leaving the block on cancellation closes it. In testing, cancelling 20 jobs left 0 contexts open this way and 6 when jobs closed the context on their last line.
Does killing a Python process leave Chromium running?
Not in testing: after SIGTERM and after SIGKILL, the Playwright driver and all Chromium processes were gone within three seconds, because the driver exits when its parent goes away.
How do I put a timeout on a whole Playwright job?
Wrap the job in async with asyncio.timeout(seconds); the cancellation unwinds through the context's async with, closing it, and surfaces as TimeoutError.
Why do I see defunct Chromium processes in Docker?
Python is PID 1 and does not reap exited children. Run the container with an init such as tini or docker run --init.
Related¶
- Browser Automation — up to the topic overview.
- Keeping headless browser memory bounded — what accumulates even without leaks.
- Network I/O & Protocol Handling — the section overview.