render-bolt
Dashboard

Running headless Chrome in production: what breaks, and when to stop doing it yourself

Memory spikes, zombie processes, missing fonts, cookie banners and requests to your own network. What it takes to run Puppeteer or Playwright for PDFs and screenshots, and when an API is the better deal.

page.pdf() takes ten lines on a laptop. The trouble starts when the same code runs on a server, for real users, all day. These are the problems that show up, roughly in the order teams hit them, with a fix for each. At the end there is an honest look at when to keep running it yourself.

1. Memory: one busy minute takes down the app#

Every open page is a renderer process, and a heavy page can use a few hundred MB on its own. Five users asking for a PDF at the same moment on a small instance is enough to run out of memory. The kernel then kills something, and often it is your web server rather than Chrome.

What helps:

  • Never launch Chrome inside the request handler. Put jobs on a queue and have a worker process them with a fixed concurrency.
  • Keep browsers warm and give each job a fresh context. Launching Chromium costs a second or more; opening a new incognito context on a running browser is fast and keeps jobs isolated.
  • Restart browsers regularly. Long-lived Chromium processes slowly grow. Recycle each one after a fixed number of jobs.
// one browser, many isolated jobs
const browser = await chromium.launch();
async function render(html) {
  const context = await browser.newContext();
  try {
    const page = await context.newPage();
    await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
    return await page.pdf({ format: 'A4', printBackground: true });
  } finally {
    await context.close();
  }
}

2. Hung pages and zombie processes#

Some pages never finish loading: a websocket that stays open, an analytics script that retries forever, a site that answers with a slow trickle. Without a hard limit, a worker slot is gone for good. And in Docker, Chrome child processes that lose their parent stay around as zombies, because nothing reaps them.

What helps:

  • A wall-clock timeout per job, not only Playwright's navigation timeout. When it fires, close the context; if closing hangs too, kill the browser and start a new one.
  • An init process in the container (dumb-init, tini, or init: true in Compose) so orphaned processes are reaped.
  • Don't wait for networkidle without a cap. Pages with live tickers or chat widgets never go quiet.

3. Fonts that turn into boxes#

A stock Linux image has almost no fonts. Your Turkish customer's name loses its ğ and ş, Japanese text becomes rows of empty boxes, and emoji disappear. It often goes unnoticed until a customer sends a screenshot.

On Debian or Ubuntu images:

apt-get install -y --no-install-recommends \
  fontconfig fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji \
  fonts-liberation2 fonts-dejavu-core fonts-thai-tlwg fonts-indic
fc-cache -f

fonts-liberation2 matters more than it looks: it gives you metric-compatible stand-ins for Arial, Times and Courier, so layouts designed on Windows or a Mac don't reflow. Then render a test page in every script your customers use and look at it.

If you capture pages you don't control, most of them open with a consent pop-up. Ads shift the layout while it loads. Images below the fold are lazy-loaded and come out as grey boxes in a full-page shot.

What helps:

  • Filter lists such as EasyList and the uBlock cookie lists, applied as request blocking and cosmetic hiding, plus CSS for the big consent platforms as a fallback.
  • Scroll before a full-page capture so lazy images load, then scroll back to the top.
  • Stop animations at capture time, so you don't catch a menu halfway through sliding in.

5. Your renderer can reach your own network#

This is the one that turns into a security incident. If users can send a URL, they can send http://169.254.169.254/ (the cloud metadata service), http://localhost:6379/ or an internal admin panel. A plain check on the URL is not enough: a public page can redirect to a private address, an iframe or a script can request one, and a hostname can resolve to a public IP at check time and a private one a second later (DNS rebinding).

What helps:

  • Refuse private, loopback, link-local and metadata ranges, for IPv4 and IPv6, including disguised forms such as 0x7f.1 or 2130706433.
  • Check every request the page makes, not just the first URL.
  • Send the browser's traffic through a small proxy that resolves DNS itself and connects only to the address it checked.
  • As a last layer, block private ranges at the firewall for the render workers.

6. Serverless is its own project#

Running Chromium on AWS Lambda or similar is possible with slimmed-down builds, but you trade one set of problems for another: deployment size limits, cold starts that add a Chromium launch to the first request, a fresh font setup, and a concurrency model where every request may start its own browser. It works well for low, bursty volume; it gets expensive and slow for steady traffic.

When to keep running it yourself#

Self-hosting is the right call when:

  • Documents must never leave your infrastructure.
  • Volume is low and internal, and an occasional failed render is acceptable.
  • You need things a hosted API doesn't offer, such as converting Office files or merging PDFs. Gotenberg is a solid open-source option for that.
  • You need to script whole browser sessions, not just capture a page.

An API is the better deal when renders are customer-facing, volume is steady, or nobody on the team wants to be paged because a font is missing or a browser leaked memory overnight.

render-bolt does all of the above for you: a queue with warm browsers and recycling, hard timeouts, fonts for every major alphabet, ad and cookie-banner removal, and network checks on every request. The free plan has 100 renders a month with every feature, so you can compare the output with your own setup before deciding.

Get your free API key

100 renders a month with every feature. No card needed.