Headless Chrome in Production: 7 Failures Reproduced
Zombie processes, leaked sessions, missing fonts, sandbox errors, hung pages and silent exits in headless Chrome, each reproduced in Docker with the fix.
Headless Chrome that works on your laptop fails on a server in a small number of ways, and they repeat. We reproduced seven of them on purpose, in Docker and on macOS, measured what each one looks like, and checked the fix. If your screenshots or PDFs break only in production, the cause is probably in this table:
| Failure | How it shows up | Fix | After the fix |
|---|---|---|---|
| Zombie processes | Z state chromium and chrome_crashpad rows in ps |
docker run --init, and wait on the child you start |
0 zombies (was 3) |
| Leaked browser sessions | Memory climbs by hundreds of MB per job | quit() in finally |
All processes gone |
| Missing fonts | Boxes instead of CJK text and emoji | Install Noto CJK and Noto Color Emoji | All glyphs drawn |
Root without --no-sandbox |
Exit 1, Running as root without --no-sandbox is not supported |
Non-root user, or --no-sandbox in an isolated container |
Screenshot written |
| Hung page load | Script blocks for minutes | Page-load timeout, eager strategy, explicit wait |
Fails in 10 s, or captures in 0.06 s |
| Silent failure | Exit 0, no file | Check the output file, not the exit code | Script fails loudly |
| Theme drift | Dark screenshots from one machine, light from another | Force prefers-color-scheme |
Same theme everywhere |
This page is library-agnostic: the reproductions use Selenium, Python and the Chrome command line, but the causes sit in Chrome and Linux, so the fixes apply to Puppeteer and Playwright too. For Puppeteer-specific timeout and memory code, see the Puppeteer timeouts and memory leaks guide. For an overview of all the ways to take a screenshot, start with how to take website screenshots with code.
Tested on 2026-10-18. Linux:
debian:bookworm-slimin Docker 29.8.2 (OrbStack) with Debian's Chromium 155.0.8059.79, chromium-driver and python3-selenium. macOS: 27.0.1 on an Apple M1 Pro, Google Chrome 155.0.8059.80, Python 3.15.0,selenium==4.51.0. Every number below is from these runs.
The test image
All Linux reproductions use this image. It runs Chrome as a non-root user and includes the fonts from failure 3:
FROM debian:bookworm-slim RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ chromium chromium-driver python3 python3-selenium procps ca-certificates \ fonts-noto-cjk fonts-noto-color-emoji \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home shooter USER shooter WORKDIR /home/shooter
1. Zombie processes
A zombie process has exited, but its parent has not read its exit status, so it keeps a slot in the process table. Chrome starts several processes. If the main browser process dies, for example when it is killed after a timeout, its helpers are re-parented to PID 1. In a container, PID 1 is your app, and Python or Node apps do not reap children they did not start.
This script plays the role of an app running as PID 1. It starts Chrome, kills only the browser process, and lists what is left:
# zombie.py: run as PID 1. Starts Chrome, kills only the browser process. import os import signal import subprocess import time p = subprocess.Popen( ['chromium', '--headless', '--remote-debugging-port=9222', 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) time.sleep(3) os.kill(p.pid, signal.SIGKILL) time.sleep(2) out = subprocess.run(['ps', '-eo', 'pid,ppid,stat,comm'], capture_output=True, text=True).stdout print('\n'.join(line for line in out.splitlines() if 'chrom' in line or 'STAT' in line)) print('zombies:', sum(1 for line in out.splitlines() if ' Z' in line))
We ran it four ways:
| Setup | Zombies left |
|---|---|
docker run (script is PID 1), no wait() |
3: chromium and two chrome_crashpad |
Same, with p.wait() after the kill |
2: the two chrome_crashpad helpers, parent PID 1 |
docker run --init, no wait() |
1: chromium, parent is the script |
docker run --init, with p.wait() |
0 |
Two separate bugs show here. The direct child (chromium) is your code's job: wait on it, which Puppeteer's browser.close(), Playwright's browser.close() and Selenium's quit() do for you when they run. The orphans (chrome_crashpad) are PID 1's job, and only an init process does it. docker run --init adds Docker's bundled init; in Kubernetes, add an init such as tini as the image's entrypoint, or turn on a shared process namespace so the pause container reaps.
Each zombie holds only a process table entry, not memory. They matter at volume: a service that kills a hung browser every few minutes adds orphans until the container hits its PID limit and Chrome can no longer start.
2. Leaked browser sessions
A session that is never closed is not a zombie. It is a live browser, holding memory. We started five Selenium sessions on example.com on macOS and never called quit(), then summed the resident memory of the processes we could match to those sessions:
| Sessions open | Matched processes | Resident memory |
|---|---|---|
| 1 | 3 | 393 MB |
| 2 | 6 | 782 MB |
| 3 | 9 | 1,074 MB |
| 4 | 12 | 1,416 MB |
| 5 | 15 | 1,800 MB |
After quit() on all five |
0 | 0 |
RSS counts shared pages once per process, so the real cost is lower than the sum; treat these as an upper bound. The trend is the point: about 360 MB per leaked session on an almost empty page. A worker that leaks one session per failed job runs out of memory within hours.
The fix is structural, not a cleanup cron. Close the browser in finally, so an exception in the middle still closes it:
driver = webdriver.Chrome(options=options) try: driver.get(url) driver.save_screenshot(out) finally: driver.quit()
If you keep one browser open across jobs for speed, restart it on a schedule (for example every few hundred pages) and after every timeout, so one bad page cannot hold memory for the rest of the day. Our Puppeteer scaling guide covers browser pools.
3. Missing fonts
A slim Debian image has almost no fonts. fc-list showed 6 entries in three families: DejaVu Sans, DejaVu Sans Mono and DejaVu Serif. We rendered a test page with English, Chinese, Japanese and three emoji (😀 🚀 ✅):
- Before: the Chinese and Japanese text came out as empty boxes ("tofu"). Two of the three emoji were boxes. 😀 rendered in black and white, because DejaVu Sans has that one character.
- After
apt-get install fonts-noto-cjk fonts-noto-color-emoji: all text rendered, 🚀 and ✅ in colour, andfc-listlisted 37 entries. 😀 was still the black-and-white DejaVu glyph, because Chrome found it in DejaVu first. The Puppeteer emoji guide shows how to fix that fallback order.
There is no error for missing glyphs. The screenshot succeeds and looks wrong. Add one page with every script your users write in to your test suite, and compare its screenshot with a known good image. Web fonts add a second failure mode, capturing before the font arrives, which our font rendering study measures with pixel diffs.
4. Root and the sandbox
Chromium will not run its sandbox as root:
$ docker run --rm --user root b67-chromium chromium --headless --screenshot=/tmp/a.png https://example.com ERROR:...zygote_host_impl_linux.cc:103] Running as root without --no-sandbox is not supported. exit=1
As the non-root shooter user in the same image, it failed differently, also with exit code 1: No usable sandbox! If this is a Debian system, please install the chromium-sandbox package.... With --no-sandbox, both worked. Puppeteer reports both as "Failed to launch the browser process".
Most production images end up with --no-sandbox. If you do that, the container is the only wall between a hostile page and your server, so run as non-root, keep secrets out of the container's environment, and do not render arbitrary user URLs without validating them. Read the Chromium sandbox glossary entry for what you give up.
5. Hung page loads
One request that never finishes holds the page's load event forever. We served a page with a heading and one image whose request never returned, then loaded it with Selenium:
| Setting | Result |
|---|---|
| Default | driver.timeouts.page_load is 300 s. The script would block for five minutes. |
driver.set_page_load_timeout(10) |
TimeoutException after 10.0 s. The heading was in the DOM and save_screenshot still worked. |
options.page_load_strategy = 'eager' |
get() returned in 0.06 s at readyState interactive |
The pattern that holds up: set a short page-load timeout, catch the timeout, wait for the element you need, and capture. Do not wait for "network idle" on pages with analytics, chat widgets or long polling; it may never come. The Chrome command line has --timeout=15000, which captures whatever has rendered after 15 seconds.
Quit the browser after a timeout that you did not handle in this way, so a stuck renderer cannot carry the hang into the next job.
6. Silent failures
The Chrome command line does not use its exit code for page errors. For a host that does not resolve, it logged Page load failed: net::ERR_NAME_NOT_RESOLVED, wrote no file and exited 0. An HTTP 404 page is captured and exits 0 too. Selenium is stricter: the same URL raised WebDriverException: unknown error: net::ERR_NAME_NOT_RESOLVED, and our script exited 1.
In any pipeline, treat "the file exists and is not empty" as the success signal, not the exit code:
#!/usr/bin/env bash set -euo pipefail url=$1; out=$2 rm -f "$out" chromium --headless --no-sandbox --window-size=1280,800 --screenshot="$out" "$url" || true [ -s "$out" ] || { echo "no screenshot for $url" >&2; exit 1; }
In the test container it exited 0 for example.com and 1 for the unresolvable host.
If an error page must count as a failure, check the HTTP status before rendering. Neither the Chrome command line nor WebDriver reports it.
7. Theme drift between machines
Headless Chrome reports the operating system's colour scheme to the page. On our Mac set to Dark, prefers-color-scheme: dark was true and example.com rendered dark grey. The Linux container rendered it light. The same code produces different images on a laptop and on a server, and a visual diff flags every page.
Force the scheme. --blink-settings=preferredColorScheme=1 (light) or =0 (dark) worked on the command line and through Selenium; so did the DevTools call Emulation.setEmulatedMedia with a prefers-color-scheme feature.
What we could not reproduce: /dev/shm
Many guides say Chrome crashes in Docker because /dev/shm is only 64 MB, and recommend --disable-dev-shm-usage. We tried to make it fail: a 1280×12,000 px page with a repeating gradient, at device scale factor 2, with --shm-size=1m. Chromium 155 wrote the same 1,571,836-byte PNG with a 1 MB /dev/shm, with 64 MB, and with --disable-dev-shm-usage. Our Docker runtime (OrbStack) gave containers 7.9 GB of /dev/shm by default, which may hide the problem in daily use.
We are not saying the crash does not exist. It was reported widely with older Chrome versions and other runtimes, and the flag costs little. We could not reproduce it on this version, so measure on your own platform before you rely on either answer.
How we avoid this list
RenderScreenshot renders on Cloudflare Browser Rendering, so we do not run Chrome processes, sandboxes or /dev/shm ourselves. We still own the parts on this list that are about the page: timeouts, wait conditions, fonts the page expects, and treating a missing image as a failure. Through the API, those become parameters and errors, not process management:
curl -sS --fail-with-body https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "viewport": { "width": 1280, "height": 800 }, "browser": { "dark_mode": false }, "wait": { "until": "load", "for_selector": "p", "timeout": 15 } }' \ -o page.png
A page that does not load within wait.timeout returns an error with a code, such as timeout, and a non-zero exit from curl --fail-with-body. The error handling and retries guide lists which errors are safe to retry. Get a free API key to run your failing URLs through it.
Related in this topic
- How to Take Website Screenshots with Code: 6 Ways Take a screenshot of a website programmatically: DevTools, the Chrome CLI, Puppeteer, Playwright, Selenium or an API. Timed runs and code for each.
- Selenium Screenshots in Python, Java and C# (Selenium 4) Take a screenshot with Selenium 4 in Python, Java and C#: page, element, exact viewport and PDF. Same test in three languages, timed, with the fixes.
- Playwright Screenshots: Tested Recipes and Pitfalls Take screenshots with Playwright: page.screenshot options, deviceScaleFactor, locators, masks, measured format sizes, auth, Linux fonts and production setup.
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Website Screenshots in Node.js: Puppeteer, Playwright, API Take website screenshots in Node.js 22 with TypeScript and ESM: Puppeteer, Playwright or one fetch call to an API, with measured install cost and tested code.
- Website Screenshots in Python: Selenium, Playwright, API Capture a website as an image in Python with Selenium, Playwright or an API: measured setup and per-shot time, async batches, and fixes for common failures.