PDF4.dev renders PDFs from HTML templates via a REST API. A warm render takes about 3ms for a simple document. This article explains how the rendering pipeline works, the specific engineering decisions behind that number, and the problems we had to solve along the way.
This is not a tutorial. It is a technical walkthrough of the actual production code in lib/pdf.ts, written for developers who run their own Playwright setup or are considering a managed API.
The five-stage pipeline
Every PDF request follows the same path:
- Handlebars compilation: template HTML + data object becomes rendered HTML
- Component injection:
<pdf4-header>,<pdf4-footer>, and<pdf4-block>tags are replaced with actual component HTML - Browser rendering: Playwright loads the HTML into a Chromium page
- DOM restructuring: the page body is rewritten into a
<table>with<thead>/<tfoot>for repeating headers and footers - PDF output:
page.pdf()renders the buffer, then pdf-lib stamps page numbers if needed
| Stage | What happens | Typical time |
|---|---|---|
| Handlebars compile | Template string → rendered HTML | under 1ms (cached) |
| Component injection | Replace tags with component HTML | under 1ms |
| Browser page creation | browser.newPage() + setContent() | 1-2ms (warm) |
| DOM restructure | page.evaluate() rewrites body into table | under 1ms |
page.pdf() | Chromium renders to PDF buffer | 1-5ms |
| pdf-lib overlay (if needed) | Stamp page numbers | 1-2ms |
Total warm-path time for a simple A4 document: about 3ms. For a complex 50-row invoice table: about 13ms. These numbers are from our benchmark, measured on macOS arm64 with Node v22.
Stage 1: the singleton browser pool
The most impactful optimization is also the simplest: launch Chromium once, reuse it forever.
let browser: Browser | null = null;
export async function getBrowser(): Promise<Browser> {
if (!browser || !browser.isConnected()) {
browser = await chromium.launch({
headless: true,
args: [
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-extensions",
"--no-sandbox",
],
});
browser.on("disconnected", () => {
browser = null;
});
}
return browser;
}Cold-starting Chromium takes 40-120ms depending on the OS. On Linux in Docker, it is closer to 80-120ms because of system library loading. The singleton pattern eliminates this cost entirely after the first request.
The disconnected event handler sets the reference to null so the next request launches a fresh browser. Chromium can crash under memory pressure, and this handles it without a health-check loop.
Each PDF request creates a new page (tab), renders, then closes the page. Pages are independent and can run in parallel on the same browser instance.
Stage 2: Handlebars compilation with caching
Templates use Handlebars syntax: {{company_name}}, {{#each items}}, {{formatCurrency total "EUR"}}. The compilation step turns a template string into a JavaScript function, then calls it with the data object.
Compilation is expensive relative to execution. A 500-line template takes a few milliseconds to compile but microseconds to execute. We cache compiled templates in a Map with an LRU eviction policy:
const templateCache = new Map<string, HandlebarsTemplateDelegate>();
const MAX_CACHE_SIZE = 100;
function getCompiledTemplate(html: string): HandlebarsTemplateDelegate {
let compiled = templateCache.get(html);
if (!compiled) {
if (templateCache.size >= MAX_CACHE_SIZE) {
const firstKey = templateCache.keys().next().value as string;
templateCache.delete(firstKey);
}
compiled = Handlebars.compile(html);
templateCache.set(html, compiled);
}
return compiled;
}The cache key is the raw HTML string. This means identical templates hit the cache even across different users. The 100-entry limit prevents unbounded memory growth. In practice, most applications use 5-20 templates repeatedly, so the hit rate is high.
Built-in helpers
We register custom Handlebars helpers for common formatting tasks: formatNumber, formatDate, formatCurrency, uppercase, lowercase, padStart, and comparison/math helpers (eq, gt, math). These run during compilation, before the HTML reaches the browser. A call like {{formatCurrency 1500 "EUR" "fr-FR"}} becomes 1 500,00 € in the rendered HTML.
Stage 3: the thead/tfoot trick for repeating headers
This is the problem that took the longest to solve correctly.
Why CSS position:fixed does not work
The intuitive approach for repeating headers and footers is position: fixed. In a browser viewport, a fixed element stays pinned while content scrolls. You would expect the same behavior in paged media: the header appears at the top of every page.
It does not work. Chromium's print engine handles position: fixed inconsistently in paged media. The element renders on the first page, then either disappears, overlaps content, or shifts position on subsequent pages. This is not a bug, it is undefined behavior in the CSS Paged Media Level 3 spec, and Chromium does not implement it reliably.
The table structure solution
The CSS 2.1 specification defines that <thead> and <tfoot> elements inside a <table> should repeat on every printed page. Chromium implements this correctly. So we restructure the DOM after loading the HTML:
// Inside page.evaluate():
const header = document.querySelector('[data-pdf4-fixed="top"]');
const footer = document.querySelector('[data-pdf4-fixed="bottom"]');
// Strip fixed positioning
header.style.position = "static";
footer.style.position = "static";
// Build the table structure
const table = document.createElement("table");
table.style.cssText = "width:100%;border-collapse:collapse;table-layout:fixed;";
// Header → thead
const thead = document.createElement("thead");
const tr1 = document.createElement("tr");
const td1 = document.createElement("td");
td1.style.cssText = "padding:0;border:none;";
td1.appendChild(header);
tr1.appendChild(td1);
thead.appendChild(tr1);
table.appendChild(thead);
// Footer → tfoot
const tfoot = document.createElement("tfoot");
const tr2 = document.createElement("tr");
const td2 = document.createElement("td");
td2.style.cssText = "padding:0;border:none;";
td2.appendChild(footer);
tr2.appendChild(td2);
tfoot.appendChild(tr2);
table.appendChild(tfoot);
// Body content → tbody
const tbody = document.createElement("tbody");
const tr3 = document.createElement("tr");
const td3 = document.createElement("td");
td3.style.cssText = "padding:0;border:none;vertical-align:top;";
while (document.body.firstChild) {
td3.appendChild(document.body.firstChild);
}
tr3.appendChild(td3);
tbody.appendChild(tr3);
table.appendChild(tbody);
document.body.appendChild(table);This runs inside page.evaluate(), which executes JavaScript in the browser context. The original HTML body is moved into a <tbody> cell. The header and footer components are moved into <thead> and <tfoot>. When page.pdf() renders, Chromium repeats them on every page automatically.
The table uses table-layout: fixed and border-collapse: collapse to eliminate any default table spacing. All <td> elements have padding: 0; border: none; so the table structure is invisible in the output.
Two footer modes
PDF4.dev supports two footer behaviors:
| Mode | Behavior | Use case |
|---|---|---|
after-content (default) | Footer appears right after the last content row | Invoices, reports |
page-bottom | Footer is pinned to the bottom of every page | Letterheads, legal documents |
In after-content mode, the footer sits inside <tfoot> and flows with content. In page-bottom mode, the footer uses position: fixed; bottom: 0 and is appended outside the table. An empty <tfoot> spacer reserves vertical space so content does not overlap with the fixed footer.
Stage 4: Google Fonts caching
Many templates use Google Fonts. Without caching, every render would make 2-3 network requests to fonts.googleapis.com and fonts.gstatic.com to download the CSS and woff2 files. At 50-200ms per request, this would dominate the render time.
We intercept font requests inside the Playwright page using route interception:
const fontCache = new Map<string, { contentType: string; body: Buffer }>();
async function setupFontCacheRouting(page: Page): Promise<void> {
await page.route(/^https:\/\/fonts\.(googleapis|gstatic)\.com\//, async (route) => {
const url = route.request().url();
const cached = fontCache.get(url);
if (cached) {
await route.fulfill({ body: cached.body, contentType: cached.contentType });
return;
}
const res = await fetch(url);
const body = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type") || "application/octet-stream";
fontCache.set(url, { contentType, body });
await route.fulfill({ body, contentType });
});
}The first render with a given font pays the network cost. Every subsequent render serves the font from memory. The cache persists for the lifetime of the server process.
We request woff2 format specifically (via a Chrome-like User-Agent header) because it is the smallest web font format, typically 30-50% smaller than woff.
Stage 5: page numbers via pdf-lib
Page numbers are a surprisingly hard problem in HTML-to-PDF rendering. Chromium's page.pdf() does not tell the HTML document how many pages the output will have. The content flows, pages break, but the HTML has no currentPage or totalPages variable.
The placeholder-then-stamp approach
PDF4.dev solves this in four steps:
- Before Handlebars compilation, variables like
{{current_page}}and{{total_pages}}in header/footer components are replaced with invisible placeholder spans:
<!-- {{current_page}} becomes: -->
<span data-pdf4-pn="current" style="color:transparent">0</span>- After rendering the HTML in the browser, we measure each placeholder's position, font size, and color using
page.evaluate():
const spans = document.querySelectorAll("[data-pdf4-pn]");
for (const span of spans) {
const rect = span.getBoundingClientRect();
const computed = getComputedStyle(span);
// Capture: absX, relY, fontSize, color, opacity, parentHeight
}-
The placeholders are made transparent (
color: transparent) so they occupy layout space but are invisible in the rendered PDF. -
After
page.pdf()produces the buffer, pdf-lib loads the PDF, iterates over every page, and draws the actual page numbers at the measured positions:
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pages = pdfDoc.getPages();
const totalPages = pages.length;
for (let i = 0; i < totalPages; i++) {
const page = pages[i];
for (const span of measuredSpans) {
const text = span.type === "current" ? String(i + 1) : String(totalPages);
page.drawText(text, {
x: computedX,
y: computedY,
size: span.fontSize * scale,
font: helvetica,
color: rgb(span.r, span.g, span.b),
});
}
}The coordinate conversion maps CSS pixels to PDF points. The scale factor is contentAreaWidthPt / viewportWidthPx, which accounts for margins and viewport sizing.
This approach works for any font, any position, any color. The numbers appear exactly where the template author placed the {{current_page}} variable.
Viewport sizing
One detail that is easy to get wrong: the Playwright viewport must match the PDF content area, not the full page.
An A4 page is 210mm wide (794px at 96 DPI). With 15mm left and right margins, the content area is 180mm wide (680px). If the viewport is set to 794px, the HTML layout is wider than the printed content area, and elements on the right edge get clipped.
const viewportW = pageDims.width
- marginToPx(pdfOptions.margin.left)
- marginToPx(pdfOptions.margin.right);
const page = await browser.newPage({
viewport: { width: Math.round(viewportW), height: Math.round(viewportH) },
});This ensures that width: 100% in CSS fills exactly the printable area. Tables, images, and flex layouts render correctly without overflow.
What happens on a real request
When you POST /api/v1/render with a template_id and data object, the server:
- Looks up the template HTML and
pdf_formatfrom SQLite - Fetches any linked component HTML (header, footer, blocks)
- Compiles Handlebars (cache hit: under 1ms)
- Replaces component tags with compiled component HTML
- Creates a Playwright page with the correct viewport
- Sets the HTML content, waits for fonts to load
- Restructures the DOM into the table structure
- Calls
page.pdf()with the resolved format options - If page numbers are needed, stamps them with pdf-lib
- Returns the PDF buffer with
Content-Type: application/pdf - Closes the page (browser stays alive)
The whole sequence takes 3-15ms for warm renders depending on document complexity. The browser stays alive for the next request.
The trade-off
Running this yourself is possible. The code patterns above work. But in production, you also need:
- Crash recovery: Chromium crashes under memory pressure. You need the
disconnectedhandler and graceful restart. - Concurrency limits: each page uses ~30MB. With 10 parallel renders, that is 300MB on top of the browser's 150MB base.
- Docker image size: Chromium plus system libraries (libglib, libnss, libatk, libcups, fonts) adds 300-500MB to your container.
- Font management: Google Fonts caching, custom font installation, fallback fonts for CJK characters.
- Monitoring: render duration, failure rate, page counts, queue depth.
PDF4.dev handles all of this. You send HTML and data, we return a PDF. Every request gets warm-path latency. Try it free, no credit card required.
Further reading
- HTML to PDF benchmark 2026: raw latency numbers for Playwright vs Puppeteer vs WeasyPrint
- CSS print styles for PDF generation: interactive guide to
@media print,@page,break-before, and widow/orphan control - Generate PDF from HTML in Node.js: step-by-step tutorial with Playwright and PDF4.dev
- Playwright vs Puppeteer for PDF generation: detailed comparison of both tools for PDF use cases
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.