All posts

gpdf vs wkhtmltopdf vs Chromium-based PDF generation

wkhtmltopdf is archived. Chromium ships a 170 MB browser per request. gpdf renders a page in 13 µs with no browser. Here's the honest 2026 comparison.

TL;DR

wkhtmltopdf was archived in January 2023. Headless Chromium (Puppeteer, Playwright, chromedp, go-rod) works but ships a ~170 MB browser binary, holds 50–120 MB resident per concurrent request, and adds 300–800 ms of cold start. gpdf generates a single PDF page in 13 µs with zero dependencies and no headless browser — at the price of not rendering arbitrary HTML+CSS.

Decision rule for the rest of the post: if your spec is "designer hands me a Tailwind page, make it pixel-perfect," Chromium is still the right tool. If your spec is "invoice, statement, report, certificate, label," the native path is a different category of cost.

Bias disclosure: we ship gpdf. The benchmark code is public, the trade-offs section names what we gave up, and the use-case matrix doesn't pretend gpdf wins everything.

The three architectures, side by side

ApproachExample toolsRender engineBinary footprintRSS / reqCold startLicense
wkhtmltopdfwkhtmltopdf CLIQtWebKit fork (~2014)~40 MB~30–80 MB~150 msLGPLv3
Chromium-basedPuppeteer, Playwright, chromedp, go-rodBlink + V8 (real Chromium)~170 MB~50–120 MB~300–800 msBSD + redistribution constraints
Native (gpdf)gpdf, signintech/gopdf, gofpdf†Pure Go PDF writer0 deps~2–10 MB0 msMIT

† gofpdf and go-pdf/fpdf are both archived; the rest of the Go landscape is in our 2026 showdown.

Three things to read off this table before we explain them.

One: wkhtmltopdf's "binary footprint" is misleadingly small. The byte count is low because its WebKit fork stopped tracking upstream more than a decade ago. The CVE backlog is not low.

Two: Chromium isn't a PDF library — it's a browser that happens to print. Every cost in that column is a browser cost.

Three: the gap between "0 ms cold start" and "300 ms cold start" isn't interesting if you run a long-lived server that generates a PDF once an hour. It's existential for serverless (Lambda, Cloud Run, Workers) and for "1,000 PDFs as fast as possible" batch jobs.

wkhtmltopdf in 2026: the state of the art

You may not need this section. If your team is already off wkhtmltopdf, skip to "Chromium-based generation."

For everyone else: wkhtmltopdf development effectively stopped in 2022, the project was archived in January 2023, and the maintainer's parting note recommended Chromium as the replacement. The reason was infrastructural. wkhtmltopdf's renderer is QtWebKit, a fork of WebKit that hasn't tracked upstream since around 2014. Qt itself deprecated QtWebKit in 2016 in favor of QtWebEngine (which wraps Chromium). The fork that wkhtmltopdf still uses is a 12-year-old browser engine.

Concretely, this means: modern CSS — full flex spec, grid, custom properties at scale, aspect-ratio, :has(), container queries, gap in flex, modern color functions — either renders wrong or doesn't render. Web fonts via @font-face mostly work; web fonts with variable axes don't. SVG support is partial. WOFF2 support landed late and is buggy.

So "use wkhtmltopdf" in 2026 has two meanings, both bad.

You're on an upstream version that ships unpatched WebKit code. Security teams will eventually flag this, and "the project is archived" is not a remediation plan. The last release shipped in 2020. CVE work since then has been done by Linux distros backporting fixes, not by upstream.

You're maintaining a private fork. Someone has to read Qt and WebKit source, backport patches, and rebuild for every platform you ship. We've seen this done. The cost is a full-time engineer who would rather be doing anything else.

The migration question is whether you replace wkhtmltopdf with Chromium (high fidelity, high cost) or with a native PDF generator (low cost, no HTML/CSS). That's the rest of this post.

Chromium-based generation: what you actually pay for

Headless Chromium is the right tool when you actually need a browser. The cost shows up in four places.

The binary. Chromium itself is ~170 MB. Playwright bundles a known-good build; Puppeteer downloads one on install (~280 MB across the three browsers it supports). In a container image, this is your largest layer by an order of magnitude. In a Lambda zip with the 250 MB limit, it's the entire deployment.

Per-process memory. A fresh Chromium process opens at ~50 MB resident. Loading a non-trivial page (real CSS, web fonts, a couple of images) pulls that to 80–120 MB. The numbers vary with the page. The floor doesn't.

Cold start. Spawning Chromium and navigating to about:blank is ~300 ms on a warm machine. Adding await page.goto(url) plus a real page load plus font fetching plus await page.pdf() is more typically 500 ms to 2 seconds on first request. Keeping a Chromium process warm in a pool helps; it doesn't help on serverless, where you pay the cold start on every scale-up event.

Operational surface. A browser is a continent of decisions you didn't intend to make: how to handle CSP, when to wait for networkidle vs load vs domcontentloaded, whether to disable JS, how to set --disable-dev-shm-usage on Docker, what to do when the browser process leaks. None of it is hard. All of it is debugging that you would rather not be doing.

There's an honest counter. When you need the fidelity, you need it. A marketing PDF designed by someone who hands you a Figma export and a Tailwind page, with custom fonts, gradients, and SVG icons that have to render exactly — that's a Chromium job. Trying to do it with a declarative document API will burn a week and produce something the designer rejects on the first review.

So the question isn't "Chromium yes/no." It's "is what I'm rendering actually a webpage."

gpdf: native rendering without a browser

gpdf is in the third category — a native Go PDF writer. No HTML, no CSS, no headless browser. You describe the document in Go (or JSON, or Go templates) and the library emits PDF bytes directly.

package main

import (
    "os"

    "github.com/gpdf-dev/gpdf"
    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    doc := gpdf.NewDocument(
        gpdf.WithPaperSize(document.A4),
        gpdf.WithMargin(document.Mm(20)),
    )

    doc.AddPage(func(p *template.PageBuilder) {
        p.Row(document.Mm(12), func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Text("Invoice", template.FontSize(24), template.Bold())
            })
        })
        p.Row(document.Mm(8), func(r *template.RowBuilder) {
            r.Col(6, func(c *template.ColBuilder) {
                c.Text("Acme Corp", template.FontSize(11))
            })
            r.Col(6, func(c *template.ColBuilder) {
                c.Text("INV-2026-0517", template.FontSize(11), template.AlignRight())
            })
        })
        // ...rows + tables + totals...
    })

    out, _ := os.Create("invoice.pdf")
    defer out.Close()
    doc.Write(out)
}

That's the whole stack. No Chromium binary in the container. No npm install puppeteer. No page.goto. The Write call emits the PDF straight to the writer — for a one-page invoice, ~13 µs of CPU time.

What we gave up to get there: the renderer doesn't know what display: flex means. It knows rows, columns (12-column grid), text runs, images, tables, barcodes. For most of the documents people generate at scale — invoices, statements, receipts, reports, certificates, labels, packing slips — that vocabulary is enough. For the rest (marketing PDFs, designer-led brochures, anything that started life as a webpage), it isn't.

The performance comparison

Benchmarking these three categories against each other is methodologically gnarly because they're solving slightly different problems. We'll do it anyway. The fair comparison is "same end product, three implementations": a one-page invoice with a header, a 4×10 line-item table, and totals.

Workloadgpdfwkhtmltopdf (CLI)Chromium (Playwright page.pdf())
Single-page invoice13 µs~140 ms~280 ms (warm) / ~1.2 s (cold)
100-page paginated report683 µs~3.4 s~6.1 s (warm)
Peak RSS during single request~5 MB~70 MB~120 MB
Container image size impact0+40 MB+170 MB

Apple M1, Go 1.25 for gpdf; wkhtmltopdf 0.12.6 binary; Playwright 1.42 with bundled Chromium. The gpdf benchmark code is in _benchmark/ — clone and re-run on your hardware.

Two numbers worth dwelling on.

The single-page invoice gap is roughly 22,000×. Most of it isn't the rendering itself — it's the cost of starting and tearing down a browser process per request. If you keep a Playwright pool warm, you cut that by ~4×; you're still at four orders of magnitude.

The 100-page report gap is roughly 9,000×. The rendering cost dominates there, and the constant overhead of "start a browser" amortizes. Even amortized, Chromium pays per-element layout costs that a native PDF writer skips.

The peak RSS number is the one that bites in production. A single Chromium process holding ~120 MB during a 6-second job means a 4 GB container can handle roughly 30 concurrent reports. The same container running gpdf handles thousands.

When each approach wins

This isn't a "gpdf wins everything" matrix. It isn't supposed to be. Real architecture decisions look like this.

Use caseRight toolWhy
Marketing PDF from a Figma + Tailwind designChromium (Playwright)Fidelity to designer intent matters more than cost.
50,000 monthly statements at month-endgpdfCost per document × volume = real money. No CSS needed.
One-off "designer asked for a brochure"Chromium (or InDesign)Volume is low, CSS is high. Use the right tool once.
Invoices for a SaaS billing systemgpdfVolume scales with revenue. Cold start matters. Layout is structured.
Tax forms / regulatory filings (PDF/A)gpdf (or unidoc)PDF/A conformance, signing, audit trails. Browsers don't do these.
Report from a BI dashboard with chart screenshotsChromiumThe chart is the point. PDF is the export.
"Print the Markdown" / docs PDFgpdf or ChromiumEither works. Trade cost for fidelity.
Legacy wkhtmltopdf migrationgpdf if HTML was simple; Chromium if CSS was realAudit the templates first.

The pattern: volume × per-request cost vs design fidelity. If the first axis dominates, native wins. If the second axis dominates, Chromium wins. wkhtmltopdf doesn't sit anywhere on this matrix in 2026.

The trade-off we don't pretend doesn't exist

We've been honest about this throughout but it deserves a section of its own.

gpdf doesn't render HTML or CSS. If your existing system is "we have an HTML email template that we also print to PDF," migrating to gpdf means rewriting the template against gpdf's builder API. For a single template, that's an afternoon. For a library of 30 designer-maintained marketing templates, it's a project.

We also don't render web fonts via @font-face. You pass TTF/OTF files to gpdf at document construction time. CJK fonts in particular are first-class — we wrote about why we render CJK without CGO — but the developer has to ship the font file.

What we don't compromise on: speed, memory, deployability, dependency footprint. The trade-off is paid in feature surface, not in production cost. We think most teams generating high-volume structured documents have been overpaying for a browser they didn't need, and the right answer for those teams is the native path. We do not think we're the right answer for every team.

Code: the same invoice, three ways

If you want to see what the API differences actually feel like, here are the three implementations side by side.

Chromium (Playwright, Node):

const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const html = fs.readFileSync('invoice.html', 'utf8');
  await page.setContent(html, { waitUntil: 'networkidle' });
  await page.pdf({
    path: 'invoice.pdf',
    format: 'A4',
    margin: { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' },
  });
  await browser.close();
})();

Plus invoice.html (which you maintain), plus the bundled Chromium binary (~170 MB), plus a way to handle font loading (web fonts? embedded base64? --font-render-hinting?). Works beautifully on a Tailwind template; the maintenance surface is the HTML.

wkhtmltopdf (shell):

wkhtmltopdf --enable-local-file-access \
  --margin-top 20mm --margin-bottom 20mm --margin-left 20mm --margin-right 20mm \
  invoice.html invoice.pdf

Plus the wkhtmltopdf binary, plus an HTML template that avoids CSS that QtWebKit-2014 doesn't understand (in practice: no grid, careful with flex, no :has(), custom properties partially work). Plus the security conversation when the binary is flagged by an audit.

gpdf (Go):

doc := gpdf.NewDocument(
    gpdf.WithPaperSize(document.A4),
    gpdf.WithMargin(document.Mm(20)),
)
doc.AddPage(func(p *template.PageBuilder) {
    invoiceHeader(p, "INV-2026-0517", "Acme Corp")
    invoiceTable(p, lineItems)
    invoiceTotals(p, subtotal, tax, total)
})
out, _ := os.Create("invoice.pdf")
defer out.Close()
doc.Write(out)

Plus three Go functions you wrote against the builder API. No template files, no binary dependency, no separate render step. Deployable as a single Go binary into a FROM scratch container.

The right way to read these isn't "which is shortest." It's "which surface area do I want to maintain." Chromium's surface is HTML + CSS + a browser; wkhtmltopdf's surface is HTML + CSS + a decade-old browser; gpdf's surface is Go.

FAQ

Is wkhtmltopdf really unusable in 2026?

Unusable is strong. Inadvisable is the right word. It still runs, it still produces PDFs, and for simple templates it produces correct PDFs. The reasons not to start a new project with it: the project is archived, the WebKit fork is a 2014 codebase, security audits will flag it, and the official replacement guidance is "use Chromium." If you have wkhtmltopdf in production today, you have time to migrate; you don't have time to add new dependencies on it.

Can't I just run Chromium and accept the cost?

For most workloads, yes. The decision matrix above puts marketing PDFs and designer-led documents firmly in Chromium's column. The reason this post exists is that Chromium is also being used for invoices, statements, and reports — workloads where the browser is paying for fidelity the document doesn't need. That's where the cost shows up in the AWS bill.

What about HTML-to-PDF without Chromium, like html2pdf or jsPDF?

Those are browser-side JS libraries that render HTML to canvas to PDF. Fidelity is significantly worse than Chromium (most modern CSS doesn't work) and perf is worse than native (you're rendering twice: HTML → canvas → PDF). They have a niche — client-side PDF generation in the browser without a server — but they're not in the same comparison.

Does gpdf support PDF/A or digital signatures?

Yes. gpdf.WithPDFA(...) for PDF/A-1b and PDF/A-2b conformance, gpdf.SignDocument(...) for PKCS#7 signatures including RFC 3161 timestamping. Both are in the core MIT library — no add-on, no commercial license.

How does gpdf compare to other Go PDF libraries (not browsers)?

Different question. Short version: gofpdf and go-pdf/fpdf are archived; signintech/gopdf is maintained but low-level (no layout grid); Maroto v2 is maintained but built on archived gofpdf; unidoc is commercial. The full comparison is in Go PDF Library Showdown 2026.

Try gpdf

gpdf is a Go PDF library. MIT, zero dependencies, native CJK.

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · Read the docs

Next reads