PDFCraft
How it works

What it takes to run headless Chromium in production

Puppeteer works in ten lines. The gap between that and something a service can depend on is where the time goes — and it fails under concurrency rather than in testing.

Never launch a browser per request

A cold browser launch is roughly ten times the cost of a warm render. Launching one per request means every user pays that, and under any concurrency at all you have a machine full of Chromiums competing for the same cores.

One long-lived browser per worker process, created at startup. A fresh BrowserContext per render — never a shared page, because pages leak state between renders in ways that produce intermittent, unreproducible wrongness.

// The shape that survives contact with traffic
const browser = await chromium.launch({ args: ['--disable-dev-shm-usage'] });

async function render(html) {
  const context = await browser.newContext();
  const page = await context.newPage();
  try {
    await page.setContent(html, { waitUntil: 'networkidle' });
    return await page.pdf({ format: 'A4' });
  } finally {
    await page.close();
    await context.close();   // both, always, in a finally
  }
}

The flag everyone finds the hard way

`--disable-dev-shm-usage`. Docker gives a container 64 MB of /dev/shm by default, Chromium uses it for shared memory, and it runs out — producing crashes that look random, correlate with load, and do not reproduce on a laptop.

Cap concurrency, and mean it

A semaphore, four pages per browser, with the queue worker concurrency set to the same number so the queue cannot outrun it. Four is not arbitrary: Chromium pins a core for the length of a render, so the useful number is close to your core count.

Every render needs a hard timeout that force-closes the context — not a promise race, an actual close. A hung page that holds a semaphore slot forever takes the whole service down one slot at a time, and the symptom is "it gets slower over a few hours" rather than an error.

It leaks; restart it

Chromium leaks memory over hours. You will not find it, and the time spent looking is time not spent on your product.

Restart each browser on a schedule — sixty minutes is a reasonable number — and drain in-flight renders first. This is not giving up; it is the correct answer for a process you did not write and do not control.

And then the rest

Fonts, because a container has almost none and your PDF silently renders in a fallback. Version pinning, because a Chromium upgrade changes pagination and your invoices reflow. Patching, because you now own a browser that renders untrusted HTML. Memory headroom, because four Chromium pages is a few hundred megabytes on a good day.

None of it is hard. All of it is work with nothing to do with your product, and the reason an API exists for this is that the list above is the same list for everyone.

See it on a real document

The extraction playground takes a PDF and shows the JSON with every bounding box drawn over the page. No key needed for the first few.

Related reading