Get started

How we render PDFs in under 300ms

Inside PDF4.dev's rendering pipeline. Singleton browser pool, Handlebars compilation cache, the thead/tfoot trick for repeating headers, and page number stamping via pdf-lib.

benoitdedApril 7, 202610 min read

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:

  1. Handlebars compilation: template HTML + data object becomes rendered HTML
  2. Component injection: <pdf4-header>, <pdf4-footer>, and <pdf4-block> tags are replaced with actual component HTML
  3. Browser rendering: Playwright loads the HTML into a Chromium page
  4. DOM restructuring: the page body is rewritten into a <table> with <thead> / <tfoot> for repeating headers and footers
  5. PDF output: page.pdf() renders the buffer, then pdf-lib stamps page numbers if needed
StageWhat happensTypical time
Handlebars compileTemplate string → rendered HTMLunder 1ms (cached)
Component injectionReplace tags with component HTMLunder 1ms
Browser page creationbrowser.newPage() + setContent()1-2ms (warm)
DOM restructurepage.evaluate() rewrites body into tableunder 1ms
page.pdf()Chromium renders to PDF buffer1-5ms
pdf-lib overlay (if needed)Stamp page numbers1-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.

PDF4.dev supports two footer behaviors:

ModeBehaviorUse case
after-content (default)Footer appears right after the last content rowInvoices, reports
page-bottomFooter is pinned to the bottom of every pageLetterheads, 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:

  1. 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>
  1. 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
}
  1. The placeholders are made transparent (color: transparent) so they occupy layout space but are invisible in the rendered PDF.

  2. 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:

  1. Looks up the template HTML and pdf_format from SQLite
  2. Fetches any linked component HTML (header, footer, blocks)
  3. Compiles Handlebars (cache hit: under 1ms)
  4. Replaces component tags with compiled component HTML
  5. Creates a Playwright page with the correct viewport
  6. Sets the HTML content, waits for fonts to load
  7. Restructures the DOM into the table structure
  8. Calls page.pdf() with the resolved format options
  9. If page numbers are needed, stamps them with pdf-lib
  10. Returns the PDF buffer with Content-Type: application/pdf
  11. 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 disconnected handler 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

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.