How to Fix Pyppeteer Browsers That Never Close and Raise Timeouts
Stop Pyppeteer leaks and timeout hangs with deterministic cleanup, coordinated navigation, event-loop fixes, and deployment checks.
Direct answer: put the entire Pyppeteer session in try/finally and call await browser.close() in the finally block. page.close() only closes one tab; it does not terminate Chromium. Use a finite navigation timeout, coordinate clicks with waitForNavigation(), await every coroutine, and verify that your launch executable, sandbox settings, and event-loop ownership match the deployment.
Reliable baseline: always close the browser
Pyppeteer is a Python port of Puppeteer for headless Chrome and Chromium automation. Its normal lifecycle is launch(), newPage(), work, then browser.close() (official repository). The API reference describes Browser.close() as closing connections and terminating the browser process (API reference).
import asyncio
from pyppeteer import launch
async def run(url: str):
browser = await launch()
try:
page = await browser.newPage()
page.setDefaultNavigationTimeout(60_000)
await page.goto(url, {"waitUntil": "domcontentloaded"})
return await page.content()
finally:
await browser.close()
asyncio.run(run("https://example.com"))
The finally block runs after successful completion, navigation errors, evaluation errors, parsing failures, and most cancellation paths. Keep it around the whole browser session rather than only around goto().
Understand what must be closed
| Object | What closing it does | When to use it |
|---|---|---|
page.close() |
Closes one tab | Discard an individual page while keeping the browser |
| Browser context close | Closes pages belonging to a context | Isolate a task when using contexts |
browser.close() |
Closes connections and terminates the Chromium process | End the complete browser session |
If your process remains after page.close(), that is expected: the browser object is still connected and Chromium is still running. Call browser.close() at the scope where the browser was created.
Use timeouts without creating leaks
Pyppeteer documents a 30,000 ms default navigation timeout. setDefaultNavigationTimeout() applies to goto(), goBack(), goForward(), reload(), and waitForNavigation(). A value of 0 disables the timeout (API reference).
page.setDefaultNavigationTimeout(60_000)
try:
await page.goto(url, {"waitUntil": "domcontentloaded", "timeout": 60_000})
except Exception as exc:
# Log the URL and operation, then let finally close the browser.
print(f"navigation failed for {url}: {exc}")
raise
Prefer a finite value that reflects the site and network conditions. Setting 0 can turn a genuinely hung navigation into a worker that never finishes, which increases resource usage and delays cleanup.
Coordinate clicks that trigger navigation
A common timeout is caused by racing a click against its navigation wait. Start both operations together so the listener is installed before the click can navigate. The Pyppeteer page source documents this asyncio.gather pattern (page source).
await asyncio.gather(
page.waitForNavigation({"waitUntil": "domcontentloaded", "timeout": 60_000}),
page.click("a.next"),
)
Use the same pattern for form submissions or JavaScript controls that replace the document. If the click does not navigate, do not wait for navigation; wait for a selector or another observable state instead.
Handle cancellation and multiple pages
async def capture_many(urls: list[str]):
browser = await launch()
try:
results = []
for url in urls:
page = await browser.newPage()
try:
page.setDefaultNavigationTimeout(60_000)
await page.goto(url, {"waitUntil": "domcontentloaded"})
results.append(await page.content())
finally:
await page.close()
return results
finally:
await browser.close()
Do not launch a new browser for every URL unless isolation requires it. Reuse one browser deliberately, close each temporary page, and close the browser once at the outer scope. If your task runner cancels a coroutine, ensure the code owning the browser still reaches its cleanup path; avoid abandoning the task that contains the finally.
Keep one event-loop owner
Every Pyppeteer coroutine must be awaited. In an async application, let the framework own the loop and call an async function directly. Do not call asyncio.run() or run_until_complete() from inside an already-running loop.
# At a command-line entry point only:
asyncio.run(run("https://example.com"))
# Inside an async framework handler:
async def handler():
html = await run("https://example.com")
return html
A reported Pyppeteer issue includes RuntimeWarning: coroutine 'Browser._targetCreated' was never awaited, which indicates coroutine scheduling or loop lifetime needs inspection (issue #179).
Diagnose launch and newPage() hangs
- Reproduce with one browser and one page, and log the exact operation that stops.
- Wrap the whole session in
try/finallyand confirm thatawait browser.close()appears in the shutdown path. - Record Python, Pyppeteer, and Chrome/Chromium versions.
- Compare the bundled executable with an explicit
executablePath. - Inspect sandbox permissions and process logs in the deployment environment.
- Check that no second event loop is being created.
browser = await launch(
executablePath="/usr/bin/google-chrome", # set only when your environment requires it
handleSIGINT=False,
handleSIGTERM=False,
handleSIGHUP=False,
)
The launch API supports executablePath, extra arguments, and signal-handler settings. A documented issue describes newPage() hanging with Python 3.11 and Chrome 115; commenters tried an OS Chrome executable and sandbox flags (issue #441). Treat --no-sandbox and --disable-setuid-sandbox as deployment-specific workarounds with security tradeoffs, not default fixes.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Chromium remains after the script exits | Only a page was closed, or an exception skipped cleanup | Call await browser.close() in finally. |
Navigation Timeout Exceeded |
Slow page, wrong wait condition, or a navigation race | Set a deliberate finite timeout; use domcontentloaded where appropriate; coordinate click and wait with asyncio.gather. |
newPage() hangs |
Executable, browser version, sandbox, or launch environment mismatch | Log versions, test executablePath, inspect permissions, and review issue-specific launch flags. |
RuntimeWarning: coroutine ... was never awaited |
Missing await or conflicting event-loop ownership |
Await every Pyppeteer call and keep one loop owner. |
| Workers accumulate over time | A browser is launched per task and never closed | Choose a browser lifetime, reuse it within that scope, close pages per task, and close the browser at scope exit. |
Timeout disappears when set to 0 |
The page is slower than the previous limit, but may still hang | Measure realistic load times and restore a finite limit with logging. |
Performance, reliability, and cost considerations
- Reuse carefully: one browser with short-lived pages avoids repeated startup work, but a crashed browser affects all pages in that process.
- Bound concurrency: too many simultaneous pages increase memory pressure and make timeouts more likely. Limit workers and close each page in its own
finally. - Choose waits intentionally:
domcontentloadedusually returns earlier than waiting for every resource. Add selector or network-idle waits only when the page requires them. - Make failures observable: log URL, operation, timeout, browser version, executable path, and exception type. This distinguishes a slow site from a launch or race problem.
- Account for retries: retry only transient navigation or launch failures, with a limit and backoff. Always run cleanup before starting a replacement browser.
- Estimate infrastructure cost: leaked Chromium processes consume memory and CPU until the worker or host is restarted. Deterministic shutdown is cheaper than relying on process reaping.
Or skip the browser setup
If your goal is a clean website screenshot rather than browser automation, ScreenshotNeo provides a single HTTP request. It accepts cookie and consent banners as a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status.
See the ScreenshotNeo API documentation for all options.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo also supports full-page capture with lazy images loaded, CSS element capture, dark mode, device presets and custom viewports, retina scale, PDFs, custom CSS and JavaScript, clicks, selector waits, delays, network-idle waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, selectable-TTL caching, signed links, asynchronous jobs with signed webhooks, bulk capture for 100 URLs per call, usage reporting, and an OpenAPI specification. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.
There is a free plan with 1,000 screenshots per month and no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan. Create a free ScreenshotNeo account.
FAQ
Does page.close() stop Chromium?
No. It closes one tab. Use browser.close() to terminate the browser process.
Should I disable navigation timeouts?
Usually no. A finite timeout exposes hung pages and lets cleanup and retries proceed. Use 0 only when an intentionally unbounded navigation is acceptable.
Why does newPage() hang while launch succeeds?
Check browser and Python versions, the selected executable, sandbox permissions, and event-loop ownership. The problem can be environment-specific.
Can I use Pyppeteer inside an async web server?
Yes. Let the server own the event loop, await Pyppeteer calls in handlers or jobs, and close the browser at the lifecycle scope you choose.
How do I avoid a click/navigation race?
Start waitForNavigation() and click() together with asyncio.gather, and use a selector wait when the click updates the page without navigation.


