Zombie Chrome processes are browser processes that a screenshot or test job leaves behind after the code that started them has gone: either true zombies (processes that have exited but were never reaped, shown as <defunct>) or, more often, orphans (Chrome processes that are still running, and still using memory, after their parent died).
Both pile up quietly on long-running servers until a container runs out of PIDs or memory. This entry is part of the screenshot glossary. The deep guide with signal handlers and health checks is Puppeteer timeouts and memory leaks.
What it means in plain terms
Unix keeps a small record for every process that exits, so its parent can read the exit code with wait(). Until the parent does so, the process is a zombie: it uses no memory or CPU, only a slot in the process table. If the parent dies first, the child is handed to PID 1, whose job is to reap it.
An orphan is different: it never exited. Its parent died, and it keeps running. A Chrome browser is a family of processes (browser, GPU, network, storage, one renderer per site), so one orphaned browser means several orphaned processes holding hundreds of megabytes.
flowchart TD N["Node worker"] -->|launch| C["Chrome browser process"] C --> R1["renderer"] & R2["renderer"] & G["GPU / utility"] N -. "worker killed (SIGKILL, OOM)" .-> X(("parent gone")) X --> O["Chrome keeps running → ORPHANS<br/>use memory and CPU"] C -. "child exits, nobody calls wait()" .-> Z["defunct entry → ZOMBIE<br/>uses a PID slot only"]
Measured: what survives when the worker is killed
We started a script that opened https://example.com and kept the browser open, then sent its Node process a signal and counted the Chrome processes 3 seconds later.
| Launch | Signal to Node | Chrome processes before | Still running after 3 s |
|---|---|---|---|
| Puppeteer, defaults | SIGINT | 8 | 0 |
| Puppeteer, defaults | SIGTERM | 8 | 0 |
| Puppeteer, defaults | SIGKILL | 8 | 8 |
Puppeteer, pipe: true |
SIGKILL | 8 | 0 |
Playwright chromium.launch() |
SIGKILL | 5 | 0 |
Method: 2026-10-15, Apple M1 Pro, macOS 27.0.1, Node 22.18.0, [email protected] (Chrome for Testing 155), [email protected] (Chromium headless shell 156). Each run used its own profile directory, so we counted only that run's processes. One run per row; the SIGKILL case repeated with the same result in a second session.
What the table shows:
- Puppeteer already handles SIGINT and SIGTERM: its default handlers close Chrome.
- SIGKILL cannot be caught. With Puppeteer's default WebSocket connection, Chrome has no way to notice that its parent is gone, so the whole browser stays alive. The same happens when the kernel's out-of-memory killer ends Node.
- With a pipe connection, Chrome reads its commands from a pipe that belongs to the parent. When the parent dies, the pipe closes and Chrome exits. Playwright uses a pipe by default, which is why it left nothing behind.
A real zombie is easy to produce in a few lines of Python: fork a child that exits, and do not call wait(). On our Mac, ps showed it with state Z and the name <defunct> until the parent called os.waitpid(); then the entry was gone.
How to prevent both
Puppeteer
import puppeteer from 'puppeteer'; // pipe: true ties Chrome's lifetime to this Node process: if Node dies, Chrome exits. const browser = await puppeteer.launch({ pipe: true }); const chrome = browser.process(); async function closeBrowser() { const timer = new Promise((r) => setTimeout(r, 5_000, 'timeout')); const result = await Promise.race([browser.close().then(() => 'closed'), timer]); if (result === 'timeout' && chrome && chrome.exitCode === null) { chrome.kill('SIGKILL'); // close() hung: force it } } try { const page = await browser.newPage(); page.setDefaultTimeout(30_000); await page.goto('https://example.com', { waitUntil: 'load' }); await page.screenshot({ path: 'example.png' }); console.log('wrote example.png'); } finally { await closeBrowser(); }
Playwright
import { chromium } from 'playwright'; const browser = await chromium.launch(); // Playwright talks to Chromium over a pipe by default try { const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await context.newPage(); await page.goto('https://example.com', { timeout: 30_000 }); await page.screenshot({ path: 'example-pw.png' }); await context.close(); // frees the renderer; reuse `browser` for the next job console.log('wrote example-pw.png'); } finally { await browser.close(); }
Both listings were run on 2026-10-15 with the versions in the method note.
In containers
Run an init process as PID 1 so exited processes are reaped: docker run --init, or init: true in Docker Compose. Node is not built to reap children it did not start, and Chrome's helper processes are re-parented to PID 1 when their parent ends. We could not run the container version of this test on the test date; the reaping rule itself is standard Unix behaviour.
RenderScreenshot
There is no browser process on your side to leak. Each request runs in a managed headless browser on Cloudflare's browser rendering service, and the wait before a capture is capped at 60 seconds (wait.timeout, see the wait options):
import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const image = await client.take(TakeOptions.url('https://example.com').waitForTimeout(30000));
This SDK snippet was not run for this page.
Common mistakes
- Closing the browser only on the happy path. Put
browser.close()infinally. - Relying on signal handlers alone. They cannot catch SIGKILL or an out-of-memory kill. Use a pipe connection as well.
- Killing by name.
pkill chromealso kills browsers that other jobs on the machine are using. Track the PID you launched, or a unique profile directory. - Node as PID 1 in Docker. Add
--init, or the process table fills with<defunct>entries. - A new browser per request. It multiplies the processes that can leak. Reuse one browser and open a new context per job, behind a concurrency limit.
Related terms
For scaling patterns that keep the process count bounded, read scaling Puppeteer screenshots. To stop managing browser processes, create a free account.