· Troubleshooting · By Simon Chiu

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-slim in 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, and fc-list listed 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