reportgen
Back to all posts

Build Your Own PDF API, Part 4: Running Headless Chrome in Production

November 2, 2026

Part 4 of 6 of Build Your Own ReportGen.

Chrome is built to run one human's tabs on a laptop, not thousands of anonymous renders on a server. Run it long enough and you'll meet all of these:

  • Memory creep. Chrome slowly grows over thousands of pages, even when you close every one.
  • Crashes. The browser process dies (out of memory, a GPU or renderer bug) and every later newPage() throws.
  • Hangs. A template with an infinite loop, or a resource that never finishes loading, keeps a page busy forever.
  • Memory spikes. Ten big reports at once means ten heavy pages at once, and the container gets killed.

reportgen.io used puppeteer-cluster for pooling. It's a good library, but it's small enough to understand fully if you write it yourself, and then you know exactly what happens when things go wrong. Here's a pool that handles all four problems in about 120 lines.


The Browser Pool

npm install ipaddr.js
// browser.js — one Chrome, bounded concurrency, self-healing, sandboxed network
const puppeteer = require("puppeteer");
const dns = require("dns").promises;
const ipaddr = require("ipaddr.js");
 
class BrowserPool {
  constructor({ concurrency = 3, recycleAfter = 200, pageTimeoutMs = 30_000 } = {}) {
    Object.assign(this, { concurrency, recycleAfter, pageTimeoutMs });
    this.active = 0;
    this.waiting = [];
    this.current = null; // { launching: Promise<Browser>, renders, inFlight, retired }
  }
 
  // Chrome leaks memory over thousands of pages, and sometimes just dies.
  // Swap in a fresh browser every `recycleAfter` renders, or when it crashes.
  // The old one closes once its in-flight pages finish.
  async checkout() {
    const cur = this.current;
    const dead = cur && cur.browser && !cur.browser.connected;
    if (!cur || dead || cur.renders >= this.recycleAfter) {
      if (cur) this.retire(cur);
      const gen = { renders: 0, inFlight: 0, retired: false, browser: null };
      gen.launching = puppeteer
        .launch({ args: ["--disable-dev-shm-usage", "--no-sandbox"] }) // see the Docker section
        .then((b) => (gen.browser = b));
      this.current = gen;
    }
    const gen = this.current;
    gen.renders++;
    gen.inFlight++;
    try {
      return { gen, browser: await gen.launching };
    } catch (err) {
      gen.inFlight--;
      if (this.current === gen) this.current = null; // let the next call retry the launch
      throw err;
    }
  }
 
  retire(gen) {
    gen.retired = true;
    if (gen.inFlight === 0) gen.launching.then((b) => b.close()).catch(() => {});
  }
 
  checkin(gen) {
    gen.inFlight--;
    if (gen.retired && gen.inFlight === 0) gen.launching.then((b) => b.close()).catch(() => {});
  }
 
  // Simple semaphore: at most `concurrency` pages open at once, the rest wait.
  async acquire() {
    if (this.active < this.concurrency) { this.active++; return; }
    await new Promise((resolve) => this.waiting.push(resolve));
  }
  release() {
    const next = this.waiting.shift();
    if (next) next(); else this.active--;
  }
 
  async withPage(fn) {
    await this.acquire();
    let gen, browser, page;
    try {
      ({ gen, browser } = await this.checkout());
      page = await browser.newPage();
      page.setDefaultTimeout(this.pageTimeoutMs);
      await lockDownNetwork(page);
      // Hard deadline for the whole render, including runaway scripts.
      return await withDeadline(fn(page), this.pageTimeoutMs + 5_000);
    } finally {
      if (page) await page.close().catch(() => {});
      if (gen) this.checkin(gen);
      this.release();
    }
  }
 
  async close() {
    if (this.current) this.retire(this.current);
  }
}
 
function withDeadline(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`render exceeded ${ms}ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
 
// --- Security: templates are untrusted input --------------------------------
// Without this, <img src="http://169.254.169.254/latest/meta-data/"> or
// <iframe src="file:///etc/passwd"> renders your secrets straight into the PDF.
 
async function isPublicHost(hostname) {
  try {
    const addrs = await dns.lookup(hostname, { all: true });
    return addrs.length > 0 && addrs.every(({ address }) => ipaddr.process(address).range() === "unicast");
  } catch {
    return false;
  }
}
 
async function lockDownNetwork(page) {
  await page.setRequestInterception(true);
  page.on("request", async (req) => {
    if (req.isInterceptResolutionHandled()) return;
    const url = new URL(req.url());
    const allowed =
      url.protocol === "data:" ||
      url.protocol === "about:" ||
      (url.protocol === "https:" && (await isPublicHost(url.hostname)));
    if (allowed) req.continue();
    else req.abort("blockedbyclient");
  });
}
 
module.exports = { BrowserPool };

(The lockDownNetwork part at the bottom is the security layer. It gets its own article: Part 5.)


What Each Piece Solves

1. Bounded concurrency → no memory spikes

acquire() and release() form a semaphore: at most concurrency pages exist at once. Extra renders wait in line instead of all opening pages together.

This is the single most important setting in your PDF service. Memory, not CPU, is what runs out. Each open page needs a chunk of RAM that grows with the document: a one-page receipt is light, a 300-page report with charts is not. Measure your heaviest real template, then pick a concurrency that fits your container with headroom. Start low (2–3 per GB of RAM is a reasonable first guess) and raise it while watching memory.

In our test, 12 renders submitted at once never had more than 3 pages open.

2. Recycling → no memory creep

After recycleAfter renders, the pool launches a fresh Chrome for new work. The old one isn't killed mid-render: it's marked retired and closes itself when its last in-flight page finishes. Users never see it happen.

A few hundred renders between recycles is a sensible default. Lower it if memory still creeps; raise it if relaunches show up in your latency.

3. Crash recovery → no "browser has disconnected" errors forever

Before each render, checkout() checks browser.connected. If Chrome died, the pool launches a new one. Without this, a single Chrome crash breaks every render until someone restarts the service.

We tested this by sending SIGKILL to the Chrome process mid-run: the next render launched a fresh browser and succeeded.

Note that the render that was in progress during the crash still fails. That's fine: the job queue from Part 3 retries it.

4. Hard deadlines → no hung renders

There are two layers:

  • page.setDefaultTimeout() makes Puppeteer calls like setContent give up after pageTimeoutMs.
  • withDeadline() wraps the whole render in a timer. This catches cases the per-call timeout misses, like a script stuck in while (true) {}, which blocks the page so completely that Puppeteer's own timeouts can't fire cleanly.

When the deadline hits, the finally block closes the page, which kills the stuck renderer, and the slot is freed for the next job. In our test, a template with an infinite loop failed cleanly at the deadline instead of blocking a worker forever.


Plugging It Into the Worker

Remember the split in Part 2's renderer: printPdf(page, html) prints on a page you hand it. Now the pool hands out pages. In the Part 3 worker, replace the browser with a pool:

const { renderHtml, printPdf } = require("./render");
const { BrowserPool } = require("./browser");
const pool = new BrowserPool({ concurrency: 3 });
 
// in processJob():
const pdf = await pool.withPage((page) => printPdf(page, html));

Then remove the puppeteer.launch() from main(), call pool.close() on shutdown instead of browser.close(), and set the worker's loop concurrency to match the pool's. If the worker claims more jobs than the pool has slots, the extra jobs sit running in the database while waiting for a page, which makes the reaper's job harder.


Chrome Flags Worth Knowing

Flag Why
--disable-dev-shm-usage Docker's default /dev/shm is 64MB. Chrome uses it for shared memory and crashes when it fills. This makes Chrome use /tmp instead.
--no-sandbox Needed when running as root in most containers. It weakens isolation, so read Part 5 and Part 6 about running as a non-root user.
--font-render-hinting=none Makes text spacing in PDFs match what you see on screen more closely on Linux. Try it if text looks slightly "off".
--disable-gpu Older advice. Current headless Chrome doesn't need it, but it's harmless.

Watch These Metrics

If you only track four things:

  1. Queue wait time: time from job created to job claimed. Rising means you need more workers.
  2. Render duration (p50 and p95): sudden jumps often mean a template change or slow external resources.
  3. Worker memory: should look like a sawtooth (grows, drops on recycle). A steady climb means a leak, usually unclosed pages.
  4. Failure rate by error type: template errors are your users' problem, timeouts and crashes are yours.

Frequently Asked Questions

Why not one browser per job for isolation? You can: set recycleAfter: 1. Every render gets a fresh Chrome. It's slower and uses more CPU, but it's the strongest isolation between tenants. A middle ground is a fresh browser context per render (browser.createBrowserContext()), which gives each render its own cookies and storage cheaply.

Should I run several browsers per worker? Usually no. One browser with several pages is lighter than several browsers with one page each. Scale out with more worker processes or containers instead (Part 6).

What about Lambda or serverless? It works with a slimmed Chromium build (like @sparticuz/chromium), and pooling becomes irrelevant since each invocation is isolated. Watch cold starts and the package size limit.


Next: Part 5: Security. That lockDownNetwork function deserves a proper explanation, because without it a PDF renderer is one of the easiest ways into your infrastructure.