Get started with 33% off your first certification using code: 33OFFNEW

How to run headless Chrome in production without it falling over

5 min read
Published on 16th July 2026

Puppeteer works first time on your laptop. Then you deploy it and the container gets OOM killed, ps fills up with defunct Chrome processes and every emoji renders as a hollow box. None of that is bad luck. Headless Chrome has a handful of production failure modes that hit almost everyone, and they all have specific fixes. This article walks through the setup that survives: the Docker image, the launch flags, the fonts and a recycling strategy that keeps memory flat.

Put Chrome in the image, not in npm install

By default Puppeteer downloads its own copy of Chrome when you run npm install. That is convenient locally and wrong in a container. The download lands in a cache directory your build probably does not persist, bloats the image and arrives without the system libraries it needs to start.

Install Chromium from apt instead, tell Puppeteer to skip its download and point it at the system binary.

FROM node:22-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    chromium \
    dumb-init \
    fonts-liberation \
    fonts-noto-color-emoji \
    fonts-noto-cjk \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/* \
    && fc-cache -f

ENV PUPPETEER_SKIP_DOWNLOAD=true \
    PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

RUN useradd --create-home renderer && chown -R renderer:renderer /app
USER renderer

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

Three details in there earn their keep. The ENV lines come before npm ci, so Puppeteer never fetches its own browser. The process runs as a dedicated renderer user rather than root, which matters for the sandbox conversation below. And everything starts under dumb-init, which fixes a zombie problem we will get to.

The launch flags that matter

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
  executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
  args: [
    '--no-sandbox',
    '--disable-setuid-sandbox',
    '--disable-dev-shm-usage',
    '--disable-gpu',
    '--hide-scrollbars',
  ],
});

--no-sandbox deserves an honest explanation rather than cargo-culting. Chrome's sandbox needs kernel features that Docker's default security profile blocks, so out of the box the browser refuses to start and everyone reaches for this flag. Dropping the sandbox is acceptable when the process runs as a non-root user and you render only your own templates. If you render HTML supplied by users, keep the sandbox instead: run the container with a seccomp profile that permits user namespaces, and drop the flag. Root plus --no-sandbox plus untrusted HTML is the combination to refuse.

--disable-dev-shm-usage fixes the most common crash. Docker gives containers a 64MB /dev/shm, Chrome uses it for shared memory and any moderately heavy page blows straight through it, killing the tab with a cryptic "session closed" error. The flag moves that memory to /tmp. The alternative is raising the allocation with --shm-size=512m on docker run and keeping Chrome's default behaviour.

--disable-gpu stops Chrome probing for hardware that is not there, and --hide-scrollbars keeps stray scrollbars out of screenshots.

Fonts and emoji, or why your renders have hollow boxes

A hollow box in a render is a missing glyph, not a bug in your HTML. Slim base images ship almost no fonts, so the first pound sign, Chinese heading or party popper falls back to nothing. The Dockerfile above installs fonts-liberation for common web fallbacks, fonts-noto-color-emoji for emoji and fonts-noto-cjk for Chinese, Japanese and Korean, then rebuilds the font cache with fc-cache -f.

Test it with content, not assumptions. Render one page containing an emoji, a few currency symbols and a line of CJK text as part of your deploy checks, and font regressions show up before your users' invoices do.

Reuse the browser, recycle it on a schedule

puppeteer.launch() starts a whole browser and costs a second or more, so launching per request wrecks your latency. Keep one browser for the process and open a fresh page per job. But Chrome accumulates memory over hours no matter how tidily you close pages, so retire the browser after a set number of renders and start a clean one.

let browser = null;
let renders = 0;
const MAX_RENDERS = 200;

async function getBrowser() {
  if (!browser || !browser.connected) {
    browser = await puppeteer.launch(launchOptions);
    browser.on('disconnected', () => { browser = null; });
    renders = 0;
  }
  return browser;
}

export async function render(html) {
  const instance = await getBrowser();
  const page = await instance.newPage();

  try {
    await page.setViewport({ width: 1200, height: 630 });
    await page.setContent(html, { waitUntil: 'networkidle0', timeout: 15000 });
    return await page.screenshot({ type: 'png' });
  } finally {
    await page.close().catch(() => {});

    if (++renders >= MAX_RENDERS) {
      const retiring = browser;
      browser = null;
      await retiring.close().catch(() => {});
    }
  }
}

The finally block is the important part. A page that fails to close lives on as a tab holding 50MB or more, and a week of leaked tabs is exactly how renderers die on a Friday night. The disconnected handler covers the other direction: if Chrome crashes, the next call relaunches instead of throwing against a dead connection.

Tune MAX_RENDERS to your workload. Two hundred is a sensible start. Watch the container's memory graph and adjust.

Zombie processes and the PID 1 problem

Chrome is not one process. It spawns a tree of renderers and helpers, and when the parent dies uncleanly the children are orphaned. Whatever runs as PID 1 in the container is supposed to reap them, but Node does not, so ps slowly fills with <defunct> entries until the process table is exhausted and nothing can fork.

The fix costs one line: run under a real init. The Dockerfile above uses dumb-init as the entrypoint. docker run --init does the same job with tini if you would rather not add a package.

Cap concurrency and set a ceiling

Each open page costs roughly 50MB to 150MB depending on the content, so uncapped concurrency is a memory spike with extra steps. Put a queue in front of the renderer and hold concurrent pages to a small number. Four or five per gigabyte is a reasonable rule of thumb, and a tiny semaphore or p-limit is enough to enforce it.

Then assume it will still fall over eventually and make that boring:

docker run --memory=1g --restart=unless-stopped renderer

A hard memory limit turns a slow leak into a quick restart, and the restart policy makes recovery automatic. A renderer should be disposable. If yours needs nursing back by hand, that is the design flaw, not the crash.

When to stop running your own browsers

Everything above is table stakes, and the list does not shrink. Chromium ships security releases every few weeks, and a renderer parses untrusted input for a living, so you patch on its schedule, not yours. Fonts drift, flags change between major versions and the memory ceiling needs re-tuning whenever your templates get heavier.

That is a fair trade when rendering is your product. When it is one feature, an invoice image here, an Open Graph card there, the operations bill dwarfs the feature. In that case, moving the render behind a hosted HTML to image API collapses this entire article into a single HTTP request, with the browser fleet, patching and font stack handled on the other side. We have covered that route before, both turning any URL into a screenshot from Node.js and automating website screenshots in Laravel.

Run your own fleet when the browser is your product. Rent one when it is your plumbing.