Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Explainer

PDF Generator API: Buy, Build, or Run Headless Chrome Yourself

Sean

Platform Writer

Aug 27, 2026
8 min read

Almost every PDF generation service is headless Chrome printing an HTML page. The question is not which rendering approach to use - it is whether running that browser is your problem or someone else’s.

PDF Generator API: Buy, Build, or Run Headless Chrome Yourself

The appeal of HTML-to-PDF is obvious: you already know how to build a page, and a designer can change the invoice layout without learning a PDF library. The cost is that you now depend on a browser, and browsers are large, memory-hungry, and awkward to operate in a container.

Table of contents

Why HTML-to-PDF won

The alternative is a PDF library where you position elements by coordinate. Those libraries are fast, small, and produce precise output, and writing an invoice layout in one is genuinely unpleasant.

HTML-to-PDF inverts the trade. You write the template in HTML and CSS, style it with the tools you already use, and let a browser handle pagination, text flow, fonts, and page breaks. The template becomes something a front-end developer or a designer can edit.

CSS has real support for this, and the print-specific properties are worth knowing because they solve the problems people usually hack around.

@page {
  size: A4;
  margin: 20mm 15mm;
}

@media print {
  .no-print { display: none; }

  /* Keep rows and headings intact across pages */
  tr, h2, h3 { break-inside: avoid; }
  h2, h3 { break-after: avoid; }

  /* Repeat table headers on every page */
  thead { display: table-header-group; }
  tfoot { display: table-footer-group; }

  /* Force a new page */
  .page-break { break-before: page; }
}

That table header rule is the one people most often need and least often know about. A multi-page invoice or report whose column headings appear only on the first page looks broken, and the fix is one line.

Running it yourself

Puppeteer driving headless Chrome is the standard approach, and it is not complicated to write.

import puppeteer from 'puppeteer'

let browser
async function getBrowser() {
  if (!browser || !browser.connected) {
    browser = await puppeteer.launch({
      args: ['--no-sandbox', '--disable-dev-shm-usage'],
    })
  }
  return browser
}

export async function renderPdf(html) {
  const page = await (await getBrowser()).newPage()
  try {
    await page.setContent(html, { waitUntil: 'networkidle0' })
    return await page.pdf({
      format: 'A4',
      printBackground: true,
      margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    })
  } finally {
    await page.close()
  }
}

Four details in there are the difference between a working service and a mysterious one.

  • Reuse the browser, close the pages. Launching Chrome per request costs a second or more and a large amount of memory. Leaking pages instead is the classic slow memory climb that ends in a restart loop.
  • printBackground defaults to false. Without it, every background colour and shaded table row silently vanishes, and the output looks nothing like the page.
  • The dev-shm flag matters in containers. The default shared memory allocation in Docker is small, and Chrome crashes in ways that look random without it.
  • Wait for the right signal. Waiting for network idle covers fonts and images; without it you get a PDF rendered before the webfont loaded, in a fallback typeface.

The sandbox flag deserves a caution. Disabling it is standard in containers and it does reduce isolation. If the HTML you render comes from users, that is a genuine concern - you are running untrusted content in a browser with weakened protections. Isolate the renderer from anything sensitive.

The operational cost

This is the part that decides the buy-versus-build question, and it is consistently underestimated.

Memory. A Chrome instance is hundreds of megabytes at rest and more under load. A service that renders PDFs needs meaningfully more memory than the same service without that feature, and running out means the process is killed mid-request.

Concurrency. Rendering is CPU-heavy. Several simultaneous renders on a small instance will queue or time out. You need a concurrency limit and a queue, not unbounded parallel rendering.

Container size. Chrome and its dependencies add hundreds of megabytes to an image, which lengthens every build and every deploy.

Fonts. A slim container image has almost no fonts installed. Text renders in whatever fallback exists, and non-Latin scripts render as empty boxes. Installing the fonts you need is a required step, not an optimisation.

RUN apt-get update && apt-get install -y \
      fonts-liberation fonts-noto-core fonts-noto-cjk \
    && rm -rf /var/lib/apt/lists/*

Updates. Chrome and Puppeteer versions are coupled, and both need updating for security fixes. That is ongoing maintenance on a dependency most teams would rather not think about.

When to buy instead

A hosted PDF API takes all of that away. You post HTML or a URL and receive a PDF, and someone else runs the browsers, installs the fonts, and handles the concurrency.

The trade is per-document pricing, an external dependency in your request path, and sending your document content to a third party - which matters if those documents contain personal or financial data.

  • Buy when volume is low or bursty, when PDF generation is peripheral to the product, or when you would rather not add a browser to your stack for one feature.
  • Build when volume is high enough that per-document pricing exceeds a server, when documents contain data you would rather not send elsewhere, when you need full control over rendering, or when generation must work without an external dependency.

The arithmetic is usually simpler than it looks. Take your monthly document count, multiply by the per-document price, and compare against the cost of a service instance sized to run Chrome plus the engineering time to build and maintain it. At low volumes buying wins comfortably; there is a crossover, and it is higher than most people assume because the maintenance cost is real.

If you build it, build it separately

The pattern that works: a dedicated PDF service, not PDF generation inside your main application.

The reasons are all about blast radius. Chrome’s memory profile is unlike a normal web service, so sizing an instance for both means over-provisioning for one workload. Rendering spikes should not degrade your API. A crashed renderer should not take down request handling. And scaling them independently is only possible if they are separate.

// A minimal internal-only render service
import express from 'express'

const app = express()
app.use(express.json({ limit: '5mb' }))

let inFlight = 0
const MAX_CONCURRENT = 3

app.post('/render', async (req, res) => {
  if (inFlight >= MAX_CONCURRENT) {
    return res.status(503).json({ error: 'busy' })
  }
  inFlight++
  try {
    const pdf = await renderPdf(req.body.html)
    res.type('application/pdf').send(pdf)
  } catch (err) {
    res.status(500).json({ error: 'render failed' })
  } finally {
    inFlight--
  }
})

app.listen(3000)

The concurrency cap is the most important line. Without it, a burst of requests launches more renders than the memory allows and the process is killed - taking every in-flight render with it. Returning a 503 and letting the caller retry is a much better failure than dying.

For anything but the smallest volume, put a queue in front. Generating a PDF is rarely something the user must wait on synchronously - accept the request, return an identifier, generate in the background, and notify or let them poll. That converts a timeout-prone synchronous path into a job that can retry.

On RunxBuild that shape is a Docker service deployed from a repository, sized on a plan with enough memory for Chrome, with autoscaling between a floor and ceiling for the bursts, runtime logs beside the deploy that produced them, and rollback when a render change goes wrong.

How this fits the rest of the stack

A PDF renderer is a memory-hungry service that spikes, which makes plan sizing the actual decision rather than an afterthought. The RunxBuild hosting calculator puts vCPU and RAM per plan next to the database, storage, and bandwidth lines, so you can size a render service honestly and compare it against per-document pricing with real numbers on both sides.

Useful related references:

FAQ

What is the best way to generate PDFs from HTML?

Headless Chrome, usually driven by Puppeteer, which is what most hosted PDF APIs run internally. It gives you CSS-based templates that a designer can edit and handles pagination, fonts, and page breaks properly. The real decision is whether you run that browser or pay someone else to.

Why is my generated PDF missing background colours?

The print background option defaults to false. Enable it explicitly when generating, or every background colour and shaded table row silently disappears and the output looks nothing like the page in a browser.

Why does my PDF service run out of memory?

Usually leaked pages. Chrome should be launched once and reused, with each page closed after rendering - launching per request is slow, and never closing pages produces a steady memory climb ending in a kill. Also cap concurrency, since each simultaneous render costs memory.

Why are fonts wrong or missing in my container-generated PDFs?

Slim base images ship with almost no fonts, so text renders in whatever fallback exists and non-Latin scripts appear as empty boxes. Install the font packages you need in the image explicitly - it is a required step rather than an optimisation.

Should I use a hosted PDF API or build my own?

Buy at low or bursty volume, or when PDF generation is peripheral to the product. Build when per-document pricing exceeds the cost of a server, when documents contain data you would rather not send to a third party, or when you need generation to work without an external dependency.

#pdf generator api#html to pdf#headless chrome#puppeteer#invoice generation