wkhtmltopdf is deprecated: what to use instead (and how to migrate)
wkhtmltopdf is archived and unmaintained — it stopped getting security and rendering updates, so its decade-old QtWebKit fork mangles modern flexbox/grid and web fonts and carries unpatched CVEs. Replace it with one of three current options: self-hosted headless Chromium (Puppeteer or Playwright) for full control, WeasyPrint if you want a Python engine with no browser, or a hosted Chromium PDF API if you want to keep wkhtmltopdf's one-call simplicity without owning a browser. In every case the migration is small: a shell-out to the wkhtmltopdf binary becomes a single HTTP request or library call, and modern CSS finally renders the way it does in a real browser.
Why wkhtmltopdf is a dead end
The problem isn't that wkhtmltopdf was bad — for years it was the pragmatic default. The problem is that it stopped moving. It renders with an ancient fork of QtWebKit, roughly a decade behind current browser engines. That has two consequences you can't patch around.
- **Modern CSS breaks.**
display: flexanddisplay: gridare inconsistent or ignored,@font-faceweb fonts often fail to load, and modern layout you built and tested in Chrome renders wrong or blank in the PDF. You end up rewriting invoices and reports in table-based HTML from 2009 just to make them print. - **Security has no owner.** An archived, unmaintained WebKit fork means unpatched CVEs. If wkhtmltopdf renders any HTML or URL you don't fully control, you're running a decade-old browser engine on untrusted input — a real SSRF and RCE surface, not a hypothetical one.
Neither of these gets better. There is no version 0.13 coming. So the migration question is just: which of the three real replacements fits your constraints?
The three honest replacements
1. Headless Chromium, self-hosted (Puppeteer / Playwright)
Drive real Chromium and print the page to PDF. This is the highest-fidelity option because it *is* a current browser — flexbox, grid, web fonts, SVG charts, and @media print all render exactly as they do in a Chrome tab. It's the closest thing to a drop-in mental model for anyone coming from wkhtmltopdf, except modern CSS actually works. See the glossary entry on headless Chromium PDF generation for the mechanics.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();The cost is operational, and it's exactly the cost wkhtmltopdf let you avoid. Chromium is a 300+ MB binary you now own: patching it, restarting crashed processes, containing memory leaks under load, and — the one people forget — running an SSRF guard in front of any URL you render. On serverless (Vercel, Lambda) it's worse: cold starts, bundle-size limits, and missing system fonts each produce their own class of blank-PDF bug.
2. WeasyPrint (Python, no browser)
WeasyPrint renders HTML and CSS to PDF with its own engine — no Chromium, no native browser dependency. It has genuinely good support for @page rules, page margins, running headers/footers, and paged-media CSS, which makes it excellent for documents that are structured print (invoices, statements, reports).
from weasyprint import HTML
HTML(string="<h1>Invoice #1024</h1>").write_pdf("invoice.pdf")The tradeoff is that WeasyPrint is not a browser. It implements a paged-CSS subset, not the full web platform: no JavaScript execution, and some modern flexbox/grid and visual-effects CSS is partial or unsupported. If your HTML depends on a client-side chart library or a heavy interactive component to lay itself out, WeasyPrint won't run it. For static, print-shaped HTML it's lighter and calmer than Chromium; for pixel-parity with a browser it isn't the tool.
3. A hosted Chromium PDF API
Same rendering fidelity as option 1 — it's real Chromium — but someone else owns the browser, the patching, the fonts, the SSRF guard, and the serverless failure modes. You make one HTTP request: HTML (or a URL, or a template + JSON) in, a PDF out. This is the option that most closely restores the *original appeal* of wkhtmltopdf — a single call, no infrastructure — while being a current engine instead of an archived one.
Before / after: a shell call becomes one HTTP request
Here's the migration in the most concrete form. A typical wkhtmltopdf integration is a shell-out from your app code:
# BEFORE — wkhtmltopdf CLI (archived engine, no CSS grid, unpatched CVEs)
wkhtmltopdf --enable-local-file-access invoice.html invoice.pdfThe same result against a hosted current-Chromium API is one request. Send the HTML, ask for the binary back with Accept: application/pdf, and write it to a file:
# AFTER — one HTTP call, real Chromium, modern CSS renders correctly
curl https://docweave.dev/api/v1/pdf \
-H "Authorization: Bearer dw_live_your_key" \
-H "Content-Type: application/json" \
-H "Accept: application/pdf" \
-d @- --output invoice.pdf <<'JSON'
{
"source": { "type": "html", "html": "<h1>Invoice #1024</h1>" },
"options": { "format": "A4", "printBackground": true }
}
JSONIf your invoice HTML lives in a file, the Node equivalent is just as short — read the file, POST it, get a PDF buffer:
import { readFile, writeFile } from 'node:fs/promises';
const html = await readFile('invoice.html', 'utf8');
const res = await fetch('https://docweave.dev/api/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DOCWEAVE_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/pdf',
},
body: JSON.stringify({
source: { type: 'html', html },
options: { format: 'A4', printBackground: true },
}),
});
if (!res.ok) throw new Error(`render failed: ${res.status}`);
await writeFile('invoice.pdf', Buffer.from(await res.arrayBuffer()));The key difference from wkhtmltopdf: request the binary explicitly with Accept: application/pdf. Without that header the API returns JSON metadata by default, and you'll write a JSON blob to invoice.pdf and wonder why it won't open. There's a full Node walkthrough if you want the idempotency-key and error-handling details.
How to actually migrate (a checklist)
- **Find every call site.** Grep your codebase for
wkhtmltopdf,pdfkit(the Ruby wrapper),WickedPdf,Snappy, or shell-outs to the binary. These are the integration points you're replacing. - **Fix the CSS you worked around.** Migration is the moment to delete the table-based layout hacks you added for QtWebKit and use the flexbox/grid you actually wanted — a current engine renders it.
- **Decide who owns Chromium.** Self-host (Puppeteer/Playwright) if you have the ops appetite and a non-serverless home for the browser; use WeasyPrint if your documents are static print-shaped HTML in a Python stack; use a hosted API if you want the wkhtmltopdf simplicity back without the archived engine.
- **Guard untrusted input.** If any HTML or URL you render comes from users, you need an SSRF guard regardless of option. A hosted API includes this; self-hosted Chromium is your responsibility.
- **Verify fonts and page breaks.** The two things that most commonly differ after migration are web-font loading and
page-break-*behavior. Render a known-good document first and diff the output before switching everything over.
Which one should you pick
If you want the shortest honest answer: if your team already runs container infrastructure and wants zero per-document cost, self-host Playwright and pay the ops tax. If you're in Python and your documents are structured print, WeasyPrint is the lightest calm option. If you liked that wkhtmltopdf was one call with no infrastructure — and you don't want to own a browser or its CVEs — a hosted Chromium API restores exactly that, on a modern engine. There's a fuller side-by-side in the best PDF generation API comparison, and you can paste HTML into the free HTML-to-PDF tool to see how your existing templates render on current Chromium before you commit to any of this.
FAQ
Is wkhtmltopdf deprecated?
Yes. The project is archived and no longer maintained — it stopped receiving security and rendering updates. It renders with an old fork of QtWebKit that is roughly a decade behind current browser engines, so modern CSS (flexbox, grid, web fonts) is unreliable and known CVEs go unpatched. New projects should use headless Chromium (Puppeteer/Playwright), WeasyPrint, or a hosted Chromium PDF API instead.
What is the best replacement for wkhtmltopdf?
There are three honest options: self-hosted headless Chromium via Puppeteer or Playwright (highest fidelity, but you own the browser and its ops), WeasyPrint (a Python engine that's great for static print-shaped HTML but isn't a full browser), or a hosted Chromium PDF API (the same one-call simplicity wkhtmltopdf had, on a current engine, with no infrastructure to run). Pick based on whether you want to own a browser.
Why does my flexbox or grid layout break in wkhtmltopdf?
Because wkhtmltopdf renders with an ancient QtWebKit fork that predates stable flexbox and grid support. It's not a bug you can configure away — the engine simply doesn't implement modern CSS layout consistently. Migrating to any current-Chromium renderer fixes it, since Chromium supports the same CSS your browser does.
Can I migrate off wkhtmltopdf without rewriting my HTML?
Mostly yes, and often you'll want to un-rewrite it. HTML you built with table-based hacks to satisfy QtWebKit renders fine on a modern engine, so you can delete those workarounds. The two things to re-verify after migrating are web-font loading (@font-face) and page-break behavior (page-break-* / break-inside), which are the most common sources of visual differences.
Related
Generate your first PDF in minutes.
Get an API key