Skip to content

HTML to PDF without Puppeteer

Most HTML-to-PDF code starts the same way: npm i puppeteer, page.setContent(html), page.pdf(). It works on your laptop in ten minutes. The cost shows up later, in the image size, the memory graph, and the Friday afternoon when a PDF comes out with tofu boxes where the customer’s name should be.

This page is about what that cost actually is, when paying it is still the right call, and how to move off it if it is not.

Your image gets big. puppeteer downloads a Chromium build of roughly 170–300 MB depending on platform. In a container you also need the shared libraries it links against — libnss3, libatk, libxkbcommon, libgbm, libasound2 and a couple of dozen more. A Node app that was a 120 MB image becomes a 600 MB–1 GB image. Build times and cold pulls go up with it.

Memory is per browser, not per request. A headless Chromium process is 80–150 MB at rest and climbs with page complexity; a page with large images or a long table can spike past 500 MB. If you launch one browser per request you pay the startup cost (300–800 ms) every time. If you pool browsers you now own a pool: health checks, restarts after N pages, and a decision about what to do when every worker is busy.

Zombie processes are real. A page.pdf() that throws before browser.close() leaves a Chromium process behind. In a long-running service these accumulate until the container is OOM-killed, and the failure looks like a memory leak in your application rather than what it is. Every production Puppeteer setup eventually grows a try/finally, a --single-process flag someone read about, and a supervisor that kills strays.

Fonts do not come with the container. A slim Debian image has essentially no fonts. Your PDF renders in a fallback face, non-Latin names become boxes, and emoji vanish entirely unless you install fonts-noto-color-emoji. This is the single most common “works locally, wrong in production” bug in this space, because your laptop has hundreds of fonts installed and the container has none.

Concurrency is bounded by RAM, not CPU. Four concurrent renders at 150 MB each is 600 MB before your application’s own footprint. Autoscaling on CPU will not save you, because the failure mode is memory.

Serverless is the worst case. Chromium exceeds the Lambda deployment package limit unless you use a stripped build like @sparticuz/chromium and a layer, and even then cold starts run 2–5 seconds before the first byte of HTML is parsed. Most teams end up running a separate always-warm service, which is the thing they were trying to avoid.

Rendering untrusted HTML is a security decision. page.setContent() on a string that contains user input gives that input a browser: it can fetch internal URLs (http://169.254.169.254/ for cloud metadata, or anything on your VPC), read local files through file:// if you have not disabled it, and exfiltrate over an image request. Sandboxing this properly means network egress rules and a locked-down container, not just --no-sandbox — which, despite appearing in almost every tutorial, turns the protection off.

This is not a case for never running a browser. Keep Puppeteer or Playwright when:

  • You are already running browsers for scraping or end-to-end tests. The infrastructure exists and the marginal cost of one more use is low.
  • The document needs a real browser session — a page behind a login, a chart that only exists after JavaScript runs against your API, a canvas you have to interact with before capturing.
  • The content cannot leave your network. Regulatory constraints, air-gapped deployments, or data you are simply not willing to send anywhere. A hosted service is the wrong shape for this, and no amount of encryption changes that.
  • Volume is enormous and steady. At millions of pages a month with a stable load profile, a dedicated render fleet is cheaper per page than any per-render price, and you have the headcount to run it.
  • You need something genuinely exotic — a custom Chromium build, a specific PDF/A profile, a font licence that cannot be installed on third-party infrastructure.

If none of those are true, most of what you are maintaining is a browser pool that exists to turn a string into bytes.

A template with a JSON Schema, one HTTP call, deterministic caching. The HTML lives server-side as a named, versioned template; your code sends data.

Three things change as a result. The payload is validated against the template’s schema before anything renders, so a missing field is a 422 naming the field rather than a blank box in a PDF a customer already received. Versions are immutable, so invoice@1 renders identically next year. And renders are cached on a hash of the template version, format, data and options, so the same input is free and instant the second time — which also means retries cost nothing.

before.mjs — Puppeteer
import puppeteer from "puppeteer";
import Handlebars from "handlebars";
import { readFile, writeFile } from "node:fs/promises";
const source = await readFile("templates/invoice.hbs", "utf8");
const template = Handlebars.compile(source);
export async function renderInvoice(invoice) {
const html = template(invoice);
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0", timeout: 30_000 });
await page.evaluateHandle("document.fonts.ready"); // or the fonts are wrong
const pdf = await page.pdf({
format: "Letter",
printBackground: true,
margin: { top: "0.5in", right: "0.5in", bottom: "0.5in", left: "0.5in" },
});
await writeFile(`${invoice.invoice_number}.pdf`, pdf);
return pdf;
} finally {
await browser.close(); // miss this and you leak a Chromium process
}
}

Plus: Chromium in the image, fonts-noto and fonts-noto-color-emoji in the Dockerfile, a concurrency limit so four simultaneous invoices do not OOM the box, and a decision about whether --no-sandbox is acceptable given who supplies invoice.

after.mjs — Node 22+, no dependencies
import { writeFile } from "node:fs/promises";
export async function renderInvoice(invoice) {
const res = await fetch("https://api.galleyrender.com/v1/render", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.GALLEY_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
template: "invoice@1",
format: "pdf",
data: invoice,
options: { page_size: "Letter", margin: "0.5in", print_background: true },
}),
signal: AbortSignal.timeout(60_000),
});
const payload = await res.json();
if (!res.ok) {
const err = payload.error ?? {};
if (err.type === "validation_error") {
const detail = (err.errors ?? [])
.map((f) => `${f.path}: expected ${f.expected}, received ${f.received}`)
.join("; ");
throw new Error(`Invoice data is invalid — ${detail}`);
}
throw new Error(`${err.type ?? res.status}: ${err.message ?? "Render failed"}`);
}
const file = await fetch(payload.url); // signed; no auth header
await writeFile(`${invoice.invoice_number}.pdf`, Buffer.from(await file.arrayBuffer()));
return payload; // { id, status, url, page_count, billable_units, cached, … }
}

Node 22 or newer for built-in fetch and AbortSignal.timeout. No dependencies — and note that there is no published Galley SDK yet, so do not try to npm install galley-render.

The response:

200 OK
{
"object": "render",
"id": "rnd_7hq2m4x8k1bv",
"status": "succeeded",
"template": "invoice@1",
"format": "pdf",
"engine": "chromium",
"cached": false,
"url": "https://<account>.r2.cloudflarestorage.com/galley-renders/renders/acc_9m3x/2026/09/4f1c…e7.pdf?X-Amz-Expires=3600&X-Amz-Signature=…",
"expires_at": "2026-10-16T14:20:11.004Z",
"page_count": 1,
"billable_units": 1,
"byte_size": 48213,
"content_type": "application/pdf",
"created_at": "2026-09-16T14:20:09.841Z",
"completed_at": "2026-09-16T14:20:11.004Z",
"error": null
}

And the error case, which Puppeteer does not give you at all — it renders the blank box and returns a valid PDF:

422 Unprocessable Entity
{
"error": {
"type": "validation_error",
"message": "The data payload does not match the template schema.",
"docs_url": "https://galleyrender.com/docs/errors/validation_error",
"errors": [
{
"path": "data.line_items[0].quantity",
"message": "data.line_items[0].quantity must be number",
"expected": "number",
"received": "string",
"example": 2
}
]
}
}

422 renders nothing and bills nothing.

Migration case 1: “I already have an HTML string”

Section titled “Migration case 1: “I already have an HTML string””

Create a template from it once. Template creation is free and unlimited; only renders are billed.

Terminal window
curl -sS https://api.galleyrender.com/v1/templates \
-H "Authorization: Bearer $GALLEY_API_KEY" \
-H 'content-type: application/json' \
-d "$(jq -n --rawfile src ./invoice.html '{
name: "legacy-invoice",
engine: "chromium",
source: $src,
description: "Ported from the Puppeteer service",
schema: {
type: "object",
required: ["invoice_number", "line_items"],
properties: {
invoice_number: { type: "string", examples: ["INV-1042"] },
line_items: { type: "array", items: {
type: "object",
required: ["description", "quantity", "unit_price"],
properties: {
description: { type: "string" },
quantity: { type: "number", examples: [2] },
unit_price: { type: "number", examples: [19] }
}
}}
}
}
}')"

Then render legacy-invoice@1 forever. Two rules the HTML has to follow: it must be one self-contained document — inline the CSS, no <link rel="stylesheet"> to a relative path, no file includes — and any image or font must be at a public https URL or a data: URI. Anything else is fetched through an SSRF policy and comes back as asset_blocked.

There is no “inline HTML” field on the render call: the template is always a named, versioned object. That is deliberate — it is what makes the cache key stable and the schema enforceable. If a document really is one-off, create a template, render it, and move on; the create costs nothing.

For page geometry, use @page in the template’s own CSS rather than relying on options:

@page { size: Letter; margin: 18mm }

Migration case 2: “I have a Handlebars or EJS template”

Section titled “Migration case 2: “I have a Handlebars or EJS template””

Port the expressions to Liquid. The structure of the document does not change — the HTML and CSS are untouched — only the tags. Most templates are a fifteen-minute mechanical edit.

HandlebarsEJSLiquid
{{ name }} (HTML-escaped)<%= name %>{{ name | escape }}
{{{ html }}} (raw)<%- html %>{{ html }}
{{ customer.name }}<%= customer.name %>{{ customer.name }}
{{#if paid}}…{{/if}}<% if (paid) { %>…<% } %>{% if paid %}…{% endif %}
{{#if a}}…{{else}}…{{/if}}<% } else { %>{% else %}
{{else if b}}<% } else if (b) { %>{% elsif b %}
{{#unless x}}…{{/unless}}<% if (!x) { %>{% unless x %}…{% endunless %}
{{#each items}}{{this}}{{/each}}<% items.forEach(i => { %>{% for item in items %}{{ item }}{% endfor %}
{{@index}}the loop variable{{ forloop.index0 }} (forloop.index is 1-based)
{{@first}} / {{@last}}{{ forloop.first }} / {{ forloop.last }}
{{#with buyer}}{{name}}{{/with}}{{ buyer.name }}, or {% assign b = buyer %}
{{#if (eq kind "warranty")}}<% if (kind === "warranty") { %>{% if kind == "warranty" %}
{{> header }} (partial)<%- include("header") %>no includes — inline it
{{ money total }} (helper)<%= money(total) %>{{ total | money }}
{{ formatDate issued }} (helper)<%= fmt(issued) %>{{ issued | date_medium }}

Three things catch people out.

Escaping is inverted. Handlebars escapes by default and {{{ }}} opts out; Liquid does not escape by default. A field that can contain user text needs | escape explicitly, or a customer named Smith & Sons <Ltd> breaks your markup.

Helpers do not port. money and date_medium ship as filters; everything else your Handlebars setup registered has to become a filter expression or be computed before you send the data. Computing it in your application and sending a formatted string is usually the faster answer.

Partials do not exist. A template is one self-contained document, so shared headers get pasted in. If you have five documents sharing a header, keep the header in your repo and concatenate at publish time — your build, not the render.

Publishing a change is POST /v1/templates/:name/versions, which creates name@2 and leaves name@1 rendering exactly as it did. Pin the version in anything you ship, both so old documents stay reproducible and so a template change does not invalidate your entire render cache at once.

200 renders a month free. Beyond that, $0.006 per PNG or JPG and $0.015 per PDF page, or $19/mo Starter, $79/mo Growth, $249/mo Scale. Cache hits are never billed.

For comparison, the smallest always-warm container that can hold a Chromium pool is on the order of $20–50/month before you have written any of the pooling, font installation or restart logic — and it still renders whatever HTML you hand it, without checking the data first.