How to Fix Pyppeteer’s “Browser Closed Unexpectedly” Error in Docker
Diagnose Pyppeteer’s Docker startup failures with stderr, browser paths, sandbox settings, shared memory, and a reliable container setup.
Pyppeteer’s Browser closed unexpectedly error means Chromium exited before Pyppeteer received its DevTools WebSocket endpoint. Your page code, selectors, and navigation have not run yet. In Docker, the usual causes are a sandbox policy mismatch, a browser executable that is missing or incompatible, missing Linux libraries, exhausted shared memory, or child processes that are not reaped correctly.
Fix it in this order:
- Enable
dumpio=Trueand read Chromium’s real stderr. - Make the browser source deterministic: use Pyppeteer’s bundled revision or an executable installed in the image.
- Verify the executable and its dependencies inside the final container.
- Use a non-root sandboxed browser when possible. Use
--no-sandboxonly when the container cannot provide a usable sandbox. - Run the container with
--init; try--ipc=hostwhen Chromium crashes under load. - Prove that one minimal launch works before adding concurrency or application logic.
What the error actually means
When launch() starts Chromium, Pyppeteer waits for Chromium to publish a DevTools endpoint. If Chromium exits first, Pyppeteer raises BrowserError('Browser closed unexpectedly: ...'). This is a browser-process startup failure, not a problem with page.goto() or a CSS selector.
The exception is intentionally generic. The useful message is normally printed by Chromium immediately before it exits. Pyppeteer exposes launch options including dumpio, executablePath, args, and userDataDir in its launch API.
Step 1: Capture Chromium’s real stderr
Start with a minimal script and turn on dumpio. Do not troubleshoot through your full application first; extra pages, workers, and retries can hide the original failure.
import asyncio
from pyppeteer import launch
async def main():
browser = None
try:
browser = await launch({
"headless": True,
"dumpio": True,
# Set this only when the executable is installed in the image:
# "executablePath": "/usr/bin/chromium",
"args": ["--no-sandbox", "--disable-setuid-sandbox"],
})
page = await browser.newPage()
await page.goto("https://example.com", {"waitUntil": "networkidle2"})
print(await page.title())
finally:
if browser:
await browser.close()
asyncio.get_event_loop().run_until_complete(main())
The two no-sandbox flags make this example work in many restricted containers, but they reduce browser isolation. Remove them after you have a working non-root sandboxed setup. If stderr says No usable sandbox, permissions fail, or a setuid helper cannot run, follow the sandbox branch below.
Step 2: Choose a deterministic browser
Option A: Use Pyppeteer’s bundled Chromium
Pyppeteer normally downloads its bundled Chromium on first use. The project documentation describes this download as approximately 100 MB. Download it while building the image so a production container does not depend on a runtime network connection:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& pyppeteer-install
COPY app.py .
CMD ["python", "app.py"]
pyppeteer==1.0.2
Pyppeteer works best with the Chromium revision it downloads. The project does not guarantee compatibility with arbitrary Chrome versions, so avoid silently replacing the bundled browser with a system browser unless you have verified the combination. See the project’s installation and usage documentation.
Option B: Install Chromium in the image and set an absolute path
If your base image provides a tested Chromium package, pass its absolute path:
browser = await launch({
"headless": True,
"dumpio": True,
"executablePath": "/usr/bin/chromium",
"args": ["--no-sandbox", "--disable-setuid-sandbox"],
})
The path must exist in the image that actually runs the application. A browser installed on the host is invisible to the container. Run the check in the final image:
docker run --rm -it your-image sh
command -v chromium || command -v chromium-browser || command -v google-chrome
ls -l /usr/bin/chromium
/usr/bin/chromium --version
Run the browser as the same user as the application. A root-owned profile, cache directory, or executable can produce permissions failures when the app later runs as a non-root user.
Step 3: Make sandboxing an explicit decision
The preferred production design is a non-root browser user with a functioning Chromium sandbox and the container capability and seccomp configuration required by the chosen image. Puppeteer’s Docker guidance states that its sandboxed browser image requires the SYS_ADMIN capability; its troubleshooting guide also documents --no-sandbox for environments without a usable sandbox.
| Environment symptom | Preferred fix | Fallback |
|---|---|---|
No usable sandbox or permission errors |
Run as non-root and configure the documented sandbox capability/seccomp model for your image. | Launch with --no-sandbox and --disable-setuid-sandbox, accepting weaker isolation. |
| Root process in a restricted CI/container runtime | Create and use a dedicated non-root browser user. | Use the no-sandbox flags only if the runtime cannot be changed. |
| Official browser image with sandbox enabled | Follow that image’s documented user and capability requirements. | Do not randomly add flags; inspect the image documentation and stderr. |
Do not treat --no-sandbox as a universal fix. It can hide a deployment-policy problem and removes an important browser security boundary. Use it as a constrained fallback, especially for isolated build jobs or disposable workloads.
Step 4: Give Docker a sane process and IPC setup
Chromium creates child processes. Make Docker’s PID 1 reap them:
docker run --rm --init your-image
Chromium also uses shared memory for renderer processes. Under parallel pages or heavy navigations, the container’s default /dev/shm can be too small. The official Puppeteer Docker guidance recommends host IPC for this case:
docker run --rm --init --ipc=host your-image
If host IPC is not acceptable in your environment, increase the shared-memory size instead:
docker run --rm --init --shm-size=1g your-image
Use the smallest setting that remains stable, and still inspect memory, process, and PID limits. A crash that appears only when several pages run at once is usually a resource or lifecycle problem rather than a selector problem.
Step 5: Retest with one page, then add complexity
Use this sequence:
- Launch one browser.
- Open one page.
- Navigate to
https://example.com. - Take one screenshot or print the title.
- Always close the browser in
finally. - Only then add additional pages, workers, retries, and application URLs.
This isolates startup failures from navigation timeouts and resource exhaustion. Repeatedly launching a new browser for every request is slower and can leave many processes behind; prefer a controlled browser lifecycle when your workload allows it.
Complete diagnostic Docker example
This example uses Pyppeteer’s bundled Chromium and emits browser stderr. It keeps the launch options visible so each setting can be tested independently.
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& pyppeteer-install
COPY diagnose.py .
# Use a non-root user when your sandbox configuration supports it.
# The no-sandbox flags below are a fallback for restricted containers.
CMD ["python", "diagnose.py"]
import asyncio
import os
from pyppeteer import launch
async def main():
browser = None
launch_options = {
"headless": True,
"dumpio": True,
"args": ["--no-sandbox", "--disable-setuid-sandbox"],
}
executable = os.getenv("CHROMIUM_PATH")
if executable:
launch_options["executablePath"] = executable
try:
browser = await launch(launch_options)
page = await browser.newPage()
response = await page.goto(
"https://example.com",
{"waitUntil": "networkidle2", "timeout": 30000},
)
print("status:", response.status if response else "no response")
print("title:", await page.title())
finally:
if browser is not None:
await browser.close()
asyncio.get_event_loop().run_until_complete(main())
docker build -t pyppeteer-diagnose .
docker run --rm --init --ipc=host pyppeteer-diagnose
Failure branches: symptom, cause, and fix
| What you see in stderr | Likely cause | Fix |
|---|---|---|
No usable sandbox, setuid or permission errors |
Chromium cannot initialize its sandbox under the current user or container policy. | Use a non-root sandboxed setup with the image’s documented capability/seccomp model, or use the constrained no-sandbox fallback. |
| Executable not found, invalid executable, or revision mismatch | executablePath points to a host-only path, the browser was not copied into the image, or the browser is incompatible with Pyppeteer. |
Run command -v and --version inside the final container. Use pyppeteer-install, or point to an installed executable you have tested with this Pyppeteer version. |
| Loader or shared-library errors | The selected Chromium package needs Linux libraries absent from the base image. | Install the dependencies required by that package and verify them in the final image. The generic Pyppeteer exception cannot identify the missing package by itself. |
| Works for one page, then crashes under parallel load | Shared-memory, memory, or process limits. | Try --ipc=host or a larger --shm-size, reduce concurrency, and inspect container memory and PID limits. |
| Long-running container accumulates children | PID 1 is not reaping Chromium descendants or browsers are not closed. | Use --init, close browsers in finally, and avoid launching an unbounded browser per request. |
| Works locally but not in Docker | The host has a browser, libraries, permissions, or shared memory that the image lacks. | Inspect paths, versions, libraries, user identity, and IPC settings from inside the container. |
| Startup succeeds but navigation times out | This is a later page-load problem, not the browser-startup error. | Keep dumpio for browser diagnostics, then investigate DNS, outbound networking, TLS, URL behavior, and navigation timeouts separately. |
Performance, reliability, and cost considerations
Startup and caching
Downloading the bundled Chromium during every container start adds latency and requires network access. Bake it into the image with pyppeteer-install. Keep the browser revision and Pyppeteer version pinned so a rebuild does not unexpectedly change the executable.
Concurrency
Each page consumes CPU, memory, file descriptors, renderer processes, and shared memory. Begin with one page, measure the container under your real URLs, then increase concurrency gradually. A larger worker count cannot compensate for a container with insufficient memory or /dev/shm.
Reliability
- Use a health check that launches one page and closes it.
- Log Chromium stderr and the selected executable path.
- Close browsers and pages in cleanup handlers.
- Use bounded retries for transient navigation failures, not infinite retries for startup failures.
- Record whether the failure happened during launch, navigation, rendering, or shutdown.
Cost
Self-hosting means paying for container CPU, memory, storage, image transfer, and operational time. The bundled browser image is also roughly 100 MB before your application and system dependencies. If screenshots are occasional or you do not want to maintain Chromium images, an API can move browser setup and container resource management out of your application.
Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF, so your application does not need to package Chromium or manage Docker sandbox settings.
See the ScreenshotNeo API documentation for request options. A basic call is:
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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', data));
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response reports the page verdict and billing state in X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.
You can also use full-page capture, CSS element selection, dark mode, device presets, custom viewports, retina scale, PDF paper and margin settings, custom CSS and JavaScript, clicks, selector or network-idle waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, caching with a chosen TTL, signed links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs per call, a usage API, and an OpenAPI specification.
Create a free ScreenshotNeo account for 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots.
Checklist before shipping
-
dumpio=Trueis enabled while diagnosing failures. - The browser is installed during the image build or its path is verified inside the final image.
- Pyppeteer and Chromium versions are pinned and known to work together.
- The process runs as a deliberate user with a deliberate sandbox policy.
-
--initis enabled. - Shared memory and container memory limits have been tested at expected concurrency.
- Browser cleanup runs even when navigation or screenshot code raises an exception.
- A single-page smoke test passes before worker concurrency is enabled.
FAQ
Does increasing the navigation timeout fix this error?
No. The browser has to start before navigation begins. Increase navigation timeouts only after Chromium launches successfully.
Should I always set executablePath?
No. Omit it when using Pyppeteer’s bundled Chromium. Set it only for an executable installed in the image and verified with an absolute path.
Is --no-sandbox safe?
It is less safe than a working sandbox. Prefer a non-root sandboxed container and reserve the flag for environments where the sandbox cannot be provided.
Why does the first run fail but later runs work?
The first run may be downloading Chromium or creating its cache. Install the browser during the image build and ensure the runtime user can access the cache.
When should I try --ipc=host?
Try it when a single page works but Chromium crashes with multiple pages or heavier sites. It addresses shared-memory pressure, not missing executables or sandbox permissions.


