Zombie Chrome Processes: Zombies vs Orphans

Zombie Chrome processes are browser processes left behind by a dead parent. Zombies vs orphans, a measured kill test, and launch settings that prevent both.

By Simon Chiu · Updated

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() in finally.
  • 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 chrome also 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.

For scaling patterns that keep the process count bounded, read scaling Puppeteer screenshots. To stop managing browser processes, create a free account.

Frequently asked questions

Why do Chrome processes keep running after my Node script dies?
Puppeteer connects to Chrome over a WebSocket by default. If Node is killed with SIGKILL or crashes hard, nothing tells Chrome to exit. In our test all 8 Chrome processes were still running 3 seconds after the kill. Launching with pipe: true made Chrome exit with its parent.
Is a zombie process the same as an orphan process?
No. A zombie has already exited and holds only a process table entry until its parent reaps it. An orphan is still running after its parent died. Leftover Chrome browsers that use memory are orphans; the many <defunct> entries in a container are zombies.
Does docker run --init fix zombie Chrome processes?
It fixes real zombies: tini runs as PID 1 and reaps exited processes. It does not stop an orphaned browser that is still running. For that, close the browser in finally, and tie its lifetime to your process.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.