ScreenshotNeo

BlogEngineering

How to Fix Chrome Headless Unknown Errors in Docker

Treat Chrome’s “unknown error” as a symptom. Use this Docker triage guide to identify version, sandbox, memory, protocol, GPU, and cleanup failures.

By the ScreenshotNeo team30 September 202610 min read

How to Fix Chrome Headless Unknown Errors in Docker

“Unknown error” is not a diagnosis. In a Dockerized headless browser, the same label can mean that Chrome never launched, Chrome launched but the automation client could not connect, a renderer crashed, shared memory was exhausted, a GPU path failed, or child processes were not reaped. The reliable fix is to collect the exact process output and environment details, then follow the branch that matches the evidence.

This guide gives you a repeatable sequence for Chrome, Chromium, ChromeDriver, Puppeteer, Selenium, Playwright, and CDP clients. It also explains current headless behavior, sandbox decisions, /dev/shm, logging, GPU symptoms, and process cleanup.

1. Capture the real failure before changing flags

Start by preserving everything your wrapper normally hides:

  • Complete Chrome and ChromeDriver stderr/stdout.
  • The automation library’s full exception and stack trace.
  • Chrome’s process exit code and signal, if available.
  • Chrome/Chromium version and executable path.
  • ChromeDriver version, when a driver is involved.
  • Puppeteer, Playwright, Selenium, chromedp, or other client version.
  • Docker image name and tag, CPU architecture, effective user, entrypoint, and runtime.
  • Container memory/CPU limits, /dev/shm size, and security profile.
  • The exact headless mode and launch arguments.

If a framework only reports “unknown error,” enable Chrome’s own logging. Chromium documents --log-level=0 --enable-logging=stderr; newer builds that use VLOG may provide more detail with --v=1. Send the output to your container logs rather than discarding it:

google-chrome \
  --headless \
  --no-first-run \
  --no-default-browser-check \
  --log-level=0 \
  --enable-logging=stderr \
  --v=1 \
  --remote-debugging-port=9222 \
  about:blank

Use only the flags your workload needs. The logging flags are for diagnosis; remove verbose logging after you have enough evidence.

2. Identify which failure family you have

Observed evidence Likely family First check
Chrome exits immediately; no DevTools endpoint appears Launch, binary, sandbox, dependency, or resource failure Version, executable path, user, stderr, and exit code
Chrome stays alive but client cannot create a session Driver/client mismatch or protocol connectivity Driver version, remote debugging endpoint, port binding
BUS_ADRERR, renderer crashes, or failures under large pages Shared-memory or memory pressure /dev/shm and container memory limit
WebGL, Vulkan, GPU, or rendering errors Graphics/driver configuration GPU logs and whether the workload needs hardware acceleration
Failures appear after many jobs; orphaned processes accumulate Process reaping/cleanup PID 1, child process count, and shutdown handling

Do not apply a shared-memory, sandbox, or GPU fix just because the wrapper used the words “unknown error.” Match the remedy to the observed branch.

Treat “unknown error” as a branching diagnosis: collect evidence before changing flags.
Treat “unknown error” as a branching diagnosis: collect evidence before changing flags.

3. Verify the Chrome, headless, and driver versions

Print the versions from inside the same image and as the same user that runs your application:

id
uname -m
which google-chrome || which chromium || which chromium-browser
google-chrome --version 2>/dev/null || chromium --version
chromedriver --version 2>/dev/null || true
python -c "import selenium,sys; print(sys.version); print('selenium', selenium.__version__)" 2>/dev/null || true
node -e "try { console.log(require('puppeteer/package.json').version) } catch (_) {}"

Keep the browser and driver on a compatible release line. Also check the headless mode you request. Chromium’s Headless documentation states that, as of M132, the old headless-shell implementation is no longer part of the Chrome binary; --headless=old has no effect. If you depend on the old implementation, use the separately distributed chrome-headless-shell and confirm its release instructions. The same documentation shows the current headless flow and DevTools connection examples: Chromium Headless documentation.

For Puppeteer, explicitly choose a supported mode rather than copying an old recipe:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
  headless: true,
  args: ['--remote-debugging-port=9222']
});
const page = await browser.newPage();
await page.goto('https://example.com', {waitUntil: 'networkidle2'});
await page.screenshot({path: 'page.png'});
await browser.close();

If your version supports the shell mode and you specifically need it, use the mode documented for that release, for example headless: 'shell'. Do not assume a flag from an older Puppeteer or Chrome guide still selects the same implementation.

4. Check the container user and sandbox safely

Chrome’s sandbox is a security boundary. Chrome’s developer guidance says --no-sandbox is not required when the container is correctly configured with an appropriate user. The chromedp headless-shell image demonstrates running as an unprivileged user such as nobody with a seccomp profile.

Inspect the effective identity and test a least-privilege launch:

docker run --rm --entrypoint sh your-image -c \
  'id; ls -l "$(command -v google-chrome || command -v chromium)"; \
   google-chrome --headless --dump-dom https://example.com'

Prefer a dedicated non-root user in the Dockerfile:

RUN groupadd --system browser && useradd --system --gid browser browser
USER browser

Then verify the Docker or Podman security profile, user namespaces, and kernel/runtime restrictions. If Chrome reports a sandbox initialization error, fix the user and runtime configuration first. Using --no-sandbox can make the process start, but it changes the security posture and should be a deliberate, isolated decision rather than the universal first step.

5. Investigate memory and /dev/shm only when symptoms support it

Containers commonly expose a small shared-memory filesystem. Chromium uses shared memory for browser and renderer coordination, so large pages, many tabs, videos, canvases, and parallel jobs can expose the limit. Inspect both shared memory and the overall container limit:

df -h /dev/shm
free -h
cat /sys/fs/cgroup/memory.max 2>/dev/null || true
ps -eo pid,ppid,rss,stat,cmd | grep -E 'chrome|chromium' | grep -v grep

For the chromedp headless-shell image, its README links BUS_ADRERR crashes to insufficient shared memory and gives --shm-size 2G as an example. That value is image- and workload-specific, not a universal requirement:

docker run --rm --shm-size=2g your-image

Increase memory gradually while observing the error. Also reduce concurrency, close pages promptly, avoid loading unnecessary resources, and set a container memory limit that leaves room for Chrome’s browser and renderer processes. A bigger /dev/shm cannot repair an incompatible driver or a sandbox failure.

6. Separate protocol connection errors from browser launch errors

If the Chrome process remains alive, confirm that the automation client can reach its DevTools endpoint. Start a diagnostic instance:

google-chrome \
  --headless \
  --remote-debugging-address=0.0.0.0 \
  --remote-debugging-port=9222 \
  about:blank
curl -sS http://127.0.0.1:9222/json/version
curl -sS http://127.0.0.1:9222/json/list

Inside Docker, 127.0.0.1 refers to the container. If the client runs in another container, use the service name on a shared network. If the port is published to the host, bind and publish it intentionally; do not expose an unauthenticated DevTools port to an untrusted network.

With ChromeDriver, record both sides of the version tuple and enable driver logging. With direct CDP clients, verify the WebSocket URL returned by /json/version. A browser that never creates the endpoint has a launch problem; a browser with a healthy endpoint has a client, network, or session-creation problem.

7. Handle GPU and graphics failures as a separate branch

Headless GPU behavior depends on the Linux display stack and driver configuration. Chromium’s GPU guidance notes that Linux’s default OpenGL driver detection expects an X display in some configurations, while Vulkan can work when explicitly configured. The --enable-gpu flag disables forced software rendering.

Use graphics flags only when logs mention GPU, WebGL, EGL, Vulkan, or renderer initialization. For a workload that does not need hardware acceleration, test a software path and compare the logs. For a workload that does need GPU features, inspect the host driver, device permissions, and runtime device mapping instead of adding random flags.

# Diagnostic software-rendering test; keep only if appropriate for your workload
google-chrome --headless --disable-gpu --enable-logging=stderr --log-level=0 about:blank

Do not treat an X server as a prerequisite for modern headless mode. Older Xvfb recipes are often copied into containers even when the browser can run without a display server.

8. Prevent zombie Chrome processes

If one request works but repeated jobs leave children behind, inspect the process tree and your container’s PID 1:

ps -o pid,ppid,stat,cmd -ax | grep -E 'chrome|chromium' | grep -v grep

The chromedp image documentation recommends an init process for reaping children; older Docker guidance commonly uses tini or dumb-init. Docker can add its built-in init process:

docker run --init --rm your-image

Alternatively, make your entrypoint an init binary and ensure your application closes every browser, context, and page in a finally block. Confirm the runtime and existing entrypoint before adding a second init layer.

9. Add crash evidence when the process actually crashes

When Chrome terminates with a signal, preserve the exact build and enable core dumps where your runtime permits them:

ulimit -c unlimited
ulimit -a
# launch the browser after setting the limit

Chromium notes that sandboxed subprocesses can be exceptions to core-dump collection. Capture the container logs, exit status, architecture, browser build, and a minimal URL or script that reproduces the crash. This evidence is far more useful than the wrapper’s single-line “unknown error.”

10. A minimal, observable Docker setup

Use a small diagnostic program before adding your full application. The following Python example prints the browser identity, enables logging, waits for a page, and always closes the driver:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_argument("--headless")
options.add_argument("--no-first-run")
options.add_argument("--no-default-browser-check")
options.add_argument("--enable-logging=stderr")
options.add_argument("--log-level=0")

# Add --no-sandbox only after you have evaluated the container user and security profile.
driver = webdriver.Chrome(options=options)
try:
    driver.get("https://example.com")
    print(driver.title)
    driver.save_screenshot("example.png")
finally:
    driver.quit()

Run this with one browser, one tab, and one URL. Once it is stable, add concurrency, custom flags, proxies, authentication, and large pages one change at a time. That makes the first regression attributable.

11. Troubleshooting checklist

Symptom or message Cause to investigate Fix to try
session not created Browser/driver incompatibility or wrong executable Print both versions and paths; align supported releases.
Chrome exits before the client connects Sandbox, missing dependency, invalid flag, or resource limit Run as the effective user, preserve stderr, and test a minimal launch.
BUS_ADRERR or renderer crash Shared-memory pressure in the specific image/workload Inspect /dev/shm; test a larger allocation such as --shm-size=2g.
WebSocket/DevTools connection refused Chrome is down, bound to another address, or unreachable across containers Query /json/version; check port binding and network service names.
WebGL/EGL/Vulkan errors GPU or display-driver path Reproduce with explicit graphics diagnostics; choose software or correctly configure GPU.
Works once, then degrades Leaked pages, browser processes, memory, or unreaped children Close resources, limit concurrency, inspect RSS and add an init process.
--headless=old has no effect Old headless was removed from the Chrome binary in M132 Use current headless or the separately distributed headless shell.

12. Performance, reliability, and operating cost

  • Concurrency: More tabs multiply renderer memory and shared-memory demand. Establish a safe worker count from observed RSS and error rates.
  • Startup: Reusing a browser can reduce launch overhead, but isolate jobs with separate contexts and recycle the browser when memory grows.
  • Page weight: Block unnecessary ads, trackers, fonts, or media only when the capture does not require them. This reduces load time but can change page behavior.
  • Timeouts: Distinguish navigation timeout, browser startup timeout, and protocol timeout in logs. Retrying a deterministic version or sandbox failure only adds load.
  • Reliability: Pin image and browser versions, record architecture, run a minimal smoke capture after upgrades, and retain stderr for failed jobs.
  • Cost: Container CPU time, memory, bandwidth, and image maintenance are part of the operating cost. Measure failure retries as well as successful captures.

13. Or skip the browser setup

If your goal is a reliable website screenshot rather than maintaining Chrome inside Docker, ScreenshotNeo provides a single GET request for PNG, JPEG, WebP, or PDF. Its capture pipeline accepts cookie and consent banners like a visitor, then removes more than 60 known consent platforms along with newsletter popups and chat widgets before the shot. Each step can be turned off.

A capture pipeline can remove common overlays before producing the screenshot.
A capture pipeline can remove common overlays before producing the screenshot.

Only clean shots are billed. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and the response identifies the result with X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the full parameter reference in the ScreenshotNeo documentation. The same request works from cURL, Python, or Node.js:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

You can request full-page captures with lazy images loaded, a CSS-selected element, dark mode, device presets or any viewport, retina scale, custom CSS and JavaScript, clicks, selector or network-idle waits, blocked resources, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs, PDFs, HTML/CSS-to-image, and usage data. Every feature is available on every plan. The free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.

14. FAQ

Do I always need --no-sandbox in Docker?

No. First run Chrome as a properly configured unprivileged user and inspect the runtime security profile. Disable the sandbox only as a considered exception.

Is --shm-size=2g a standard Chrome requirement?

No. It is an example from the chromedp headless-shell image for a BUS_ADRERR crash context. Measure your workload and increase shared memory when the evidence points there.

Should I install Xvfb for headless Chrome?

Modern headless Chrome does not generally require Xvfb. Add a display server only when your selected browser mode or graphics workload specifically requires it.

How do I know whether Chrome or the automation library failed?

Check whether Chrome stayed alive and whether /json/version responds. No process or endpoint means launch failure; a healthy endpoint points toward the client, driver, network, or session setup.

When should I use a hosted screenshot API?

Use one when browser installation, sandboxing, shared memory, upgrades, and cleanup are not part of the product you want to operate. ScreenshotNeo is an option when you need those capture controls without running Chrome in your container.