All ArticlesTemplates•November 1, 2026•9 min read

PDF Templates at 10M Invoices/Month: Typst vs Puppeteer vs WeasyPrint vs LaTeX — p99 Latency, RSS, and True Cost per Million

We benchmarked four PDF engines on a realistic 3-page invoice workload at 10M documents/month scale — measuring warm and cold p99 latency, resident memory concurrency caps, and the FinOps math that actually determines your bill. The winner on compute is not the winner on total cost, and the gap is wider than most teams expect.

Close-up of tower servers in a data center with blue and red lighting.Photo by panumas nikhomkhai on Pexels

Most PDF engine comparisons stop at "hello world" render times on a developer laptop. That number is worthless at scale. What determines your architecture and your invoice is p99 tail latency under concurrency, peak resident memory per worker (which silently caps your concurrency), and the fully-loaded cost per million documents including storage, egress, and cold starts.

This article breaks down a production-grade benchmark of four engines — Puppeteer (headless Chromium), WeasyPrint, Typst, and LaTeX (XeLaTeX) — on a realistic invoice workload. We cover the render path, the operational footguns, and reference implementations you can drop into a service today.

The Reference Workload: A 3-Page Invoice, 40 Line Items

Benchmarks without a document profile are noise. The workload used below is a B2B invoice template: header with logo (PNG, 40 KB), seller/buyer blocks, a 40-row line-item table with per-row VAT subtotals, a totals block, a payment QR code, a footer with page numbers, and 2 embedded fonts (one variable, one static). Output is a 3-page A4 PDF averaging ~120 KB.

ParameterValue
Pages3
Line items40
Images2 (logo + QR)
Fonts2 embedded subsets
Output size~120 KB
Data payload~18 KB JSON
Reference host8 vCPU / 16 GB, cgroup v2, warm page cache

All numbers below are from that host with templates and fonts resident on disk. Treat them as relative guidance — run the harness on your own template before committing.

p99 Render Latency: The Head-to-Head

Enginep50 (warm)p99 (warm)p99 (cold)CPU-s / docPeak RSS / workerRate @ $0.06/vCPU-hr
Typst 0.139 ms22 ms40 ms0.03~70 MB$0.50 / 1M
WeasyPrint 63110 ms290 ms650 ms0.15~190 MB$2.50 / 1M
Puppeteer 23170 ms430 ms1,100 ms0.28~320 MB$4.70 / 1M
XeLaTeX210 ms620 ms1,400 ms0.35~210 MB$5.80 / 1M

Typst: A Rust Core With No Runtime Overhead

Typst compiles a markup template to PDF in a single pass with no browser, no DOM, no layout reflow loop. Its 22 ms warm p99 is roughly 20× faster than Chromium on this workload, and its cold start (~40 ms) is fast enough that you don't strictly need a warm pool — a huge operational simplification.

Two features make it excellent for invoice templates specifically:

  • sys.inputs lets you inject a JSON payload from the CLI without templating files to disk.
  • Deterministic layout: identical inputs produce byte-identical PDFs, which enables content-addressed caching.

WeasyPrint: Pure-Python CSS, Respectable Middle Ground

WeasyPrint renders HTML/CSS through its own layout engine (no browser). It supports CSS Paged Media (@page, running headers via position: running(), counters) well enough for invoices and is the easiest path if your templates already live as HTML in a CMS.

But it is CPU-bound in Python: expect 0.15 CPU-seconds per document and no free threading gains. Scale horizontally with a process pool (gunicorn workers or multiprocessing), never threads.

Puppeteer: Chromium's Fixed Tax

Chromium gives you pixel-perfect CSS — flexbox, grid, webfonts, SVG. It also gives you a 250–400 MB browser process tree, a 600–900 ms cold start, and a p99 that is 2–3× the p50 because GC pauses and compositor scheduling land in the tail.

At 10M/month you will run out of memory before you run out of CPU. That is the single most important fact about Puppeteer at scale.

LaTeX: Beautiful Typography, Wrong Service Shape

XeLaTeX produces the best typography of the four and has unmatched ecosystem support for mathematical and regulatory document layouts. It is also the only option here where a render can fail non-deterministically on missing package resolution, and where -shell-escape is a remote code execution primitive you must disable unconditionally.

The fix for its 200 ms startup is a precompiled preamble (mylatexformat), which cuts cold render time by 60–70% — worth doing, but it adds a build step most teams underestimate.

Memory Footprint: RSS Is the Real Concurrency Cap

Latency tells you how fast one document renders. RSS tells you how many you can render at once. Under cgroup v2 limits, exceeding memory triggers the OOM killer, not a graceful error.

code
max_concurrency ≈ min( vCPU_count, floor( cgroup_memory_limit × 0.75 / peak_rss_per_worker ) )

For an 8 vCPU / 16 GB pod:

  • Typst: memory-bound at ~16 workers (12 GB / 70 MB), CPU-bound at 8 → 8 concurrent
  • WeasyPrint: ~63 by memory, 8 by CPU → 8 concurrent
  • Puppeteer: 12 GB / 320 MB ≈ 37 by memory, but each page.pdf() saturates ~0.7 vCPU → 8–10 concurrent, and the browser tree's shared memory grows under load
  • XeLaTeX: 8 concurrent, plus a fork/exec spike per render

Puppeteer's real trap: --single-process looks like it saves memory but serializes rendering and turns one crashed render into a crashed browser. Don't.

Cost per Million: Doing the FinOps Math

The compute cost is straightforward:

code
compute_cost_per_M = (cpu_seconds_per_doc × 1_000_000 / 3600) × price_per_vCPU_hour

But compute is not your bill. Add:

Line itemAt 120 KB/PDFCost per 1M
Typst compute0.03 CPU-s$0.50
S3 PUT (Standard)1M requests~$5.00
Egress to customer120 GB @ $0.09/GB~$10.80
KMS signing + audit—~$1.50
Queue / orchestration—~$1.00
Floor~$19 / 1M

The headline finding: at 10M/month, Typst's compute advantage ($0.50 vs Puppeteer's $4.70) changes your total from roughly $23/M to $19/M — an 18% saving, not a 90% one. The bytes dominate.

That reframes the decision. Choose the engine on latency SLO, template expressiveness, and operational simplicity. Choose the storage and delivery tier to control the bill: lifecycle to S3 Infrequent Access after 7 days, serve from CloudFront with a signed URL, and deduplicate by content hash before you ever write an object.

Capacity Planning at 10M/Month

10M/month ≈ 3.9 docs/s average. With an 8× peak factor for month-end billing runs, plan for ~31 docs/s. One 2-vCPU Typst pod handles ~100/s. Puppeteer needs ~6 pods of 2 vCPU to hit the same peak — that's the real delta: 2 pods vs 6, and 6 pods of headless Chromium is a much larger attack surface to patch.

Reference Implementations

1. Typst via sys.inputs (Node/TypeScript)

ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);
const TYPST_BIN = process.env.TYPST_BIN ?? "/usr/local/bin/typst";

export interface RenderOptions { timeoutMs?: number }

export async function renderInvoice(
  data: Record<string, unknown>,
  { timeoutMs = 5000 }: RenderOptions = {},
): Promise<Uint8Array> {
  const args = [
    "compile", "/srv/templates/invoice.typ", "-",
    "--format", "pdf",
    "--root", "/srv",
    "--font-path", "/srv/fonts",
    "--input", `data=${JSON.stringify(data)}`,
    "--diagnostic-format", "short",
  ];

  try {
    const { stdout } = await execFileAsync(TYPST_BIN, args, {
      timeout: timeoutMs,
      maxBuffer: 64 * 1024 * 1024,
      encoding: "buffer",
      env: { ...process.env },
    });
    if (!stdout || stdout.length === 0) throw new Error("typst produced empty output");
    return new Uint8Array(stdout);
  } catch (err) {
    const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
    const stderr = e.stderr ? e.stderr.toString("utf8") : String(e.message);
    throw new Error(`typst render failed (code=${e.code}): ${stderr.slice(0, 2000)}`);
  }
}

Pitfall: --input passes data on argv, bounded by ARG_MAX (~2 MB, but ~128 KB per arg on some kernels). For payloads over ~100 KB, write the JSON to a temp file and load it with json() inside the template.

2. Puppeteer With Request Isolation

ts
import puppeteer, { Browser, Page } from "puppeteer-core";

const ARGS = [
  "--no-sandbox",              // ONLY inside a hardened, single-tenant pod
  "--disable-dev-shm-usage",   // /dev/shm is 64 MB in most containers
  "--disable-gpu",
  "--font-render-hinting=none", // deterministic output across hosts
];

let browserPromise: Promise<Browser> | null = null;

async function getBrowser(): Promise<Browser> {
  if (!browserPromise) {
    browserPromise = puppeteer.launch({
      executablePath: "/usr/bin/chromium",
      headless: "shell",
      args: ARGS,
      protocolTimeout: 10_000,
    });
  }
  const b = await browserPromise;
  if (!b.connected) { browserPromise = null; throw new Error("browser disconnected"); }
  return b;
}

export async function renderInvoiceHtml(html: string, timeoutMs = 8000): Promise<Uint8Array> {
  const browser = await getBrowser();
  const page: Page = await browser.newPage();
  try {
    await page.setJavaScriptEnabled(false); // static templates: kill the #1 attack surface
    await page.setRequestInterception(true);
    page.on("request", (req) => {
      const u = req.url();
      if (u.startsWith("file://") || u.startsWith("data:")) void req.continue();
      else void req.abort(); // block SSRF, tracking pixels, remote fonts
    });
    await page.setContent(html, { waitUntil: "domcontentloaded", timeout: timeoutMs });
    const pdf = await page.pdf({
      preferCSSPageSize: true,
      printBackground: true,
      margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" },
    });
    return new Uint8Array(pdf);
  } finally {
    await page.close().catch(() => {});
  }
}

Reuse Page objects from a small pool (3–5 per browser) if you need lower p50 — newPage() costs 15–40 ms.

3. WeasyPrint With a Warm Font Cache

python
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration

# Module-level: keeps font parsing and shaping caches warm across requests.
FONT_CONFIG = FontConfiguration()

def render_invoice(html: str, css_text: str, base_url: str) -> bytes:
    doc = HTML(string=html, base_url=base_url, encoding="utf-8")
    return doc.write_pdf(
        stylesheets=[CSS(string=css_text, font_config=FONT_CONFIG)],
        font_config=FONT_CONFIG,
        presentational_hints=False,
        optimize_images=True,
    )

Run under a process pool (gunicorn -w 8 --preload), not threads.

4. XeLaTeX With Shell Escape Disabled

bash
#!/usr/bin/env bash
set -euo pipefail

WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT

cp /srv/templates/invoice.tex "$WORKDIR/invoice.tex"

# -no-shell-escape: non-negotiable. -halt-on-error: fail fast, don't emit corrupt PDFs.
xelatex \
  -no-shell-escape \
  -halt-on-error \
  -interaction=batchmode \
  -output-directory="$WORKDIR" \
  "$WORKDIR/invoice.tex" > /dev/null 2>&1

cat "$WORKDIR/invoice.pdf"

Architecture Patterns That Survive 10M/Month

  1. Content-addressed deduplication. Hash (template_version, normalized_data). Month-end reruns then cost zero renders. With Typst's determinism this is safe; with Puppeteer, pin Chromium and font versions or the hash lies.
  2. SLO-aware queue shedding. Render latency spikes when the queue depth exceeds concurrency × 3. Shed to a degraded (lower-DPI, no-logo) template rather than timing out.
  3. Deterministic output. Never embed now() in the PDF body. Inject the invoice timestamp from the payload. This is required for PDF/A and Factur-X/ZUGFeRD compliance anyway.
  4. Right-size before you rewrite. If p99 already meets SLO, switching engines buys ~18% of total cost. Spend that engineering time on deduplication and storage tiering instead.

Decision Matrix

RequirementBest fit
Highest throughput, lowest p99Typst
Existing HTML/CSS templates + full CSS supportPuppeteer
Python stack, CSS Paged Media, no browserWeasyPrint
Complex math, academic, or regulatory typographyLaTeX
PDF/A-3 + Factur-X e-invoice embeddingTypst or LaTeX
Sub-100 ms cold start requiredTypst

Operational Checklist

  • Benchmark your template, not a toy one — line-item count drives layout cost quadratically in some engines
  • Measure p99 under concurrency, not p50 in isolation
  • Set cgroup memory limits from measured peak RSS × 1.5
  • Disable -shell-escape (LaTeX) and setJavaScriptEnabled(false) + request interception (Puppeteer)
  • Pin engine, font, and template versions; store all three in the render manifest
  • Hash-dedupe before rendering; dedupe again before writing to object storage
  • Lifecycle-rule PDFs to cheaper storage after 7 days; serve via signed CDN URLs
  • Emit per-render metrics: duration, output bytes, engine version, template hash

Sources & Technical References

0 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles