Disclosure: I build RenderScreenshot, a hosted API that also renders PDFs with Chromium. Neither wkhtmltopdf nor Puppeteer is a product we sell, and both are free. Where the answer is "run Puppeteer yourself", this page says so.
If you are choosing between wkhtmltopdf and Puppeteer today, choose Puppeteer. wkhtmltopdf is archived, its rendering engine is a WebKit from 2012, and its own maintainers recommend Puppeteer for pages that use JavaScript. The interesting question is not which one to pick for a new project. It is what breaks when you move an existing wkhtmltopdf template to Chromium, and how much the move costs in speed and operations. This page answers both with a real invoice template, measured Puppeteer timings, and the code for both tools.
It sits under our comparison hub. If you are replacing wkhtmltopdf with a hosted service instead, read the wkhtmltopdf alternative guide.
Last verified: October 15, 2026, against the wkhtmltopdf status page, the wkhtmltopdf GitHub repository and the Puppeteer page.pdf() reference.
The short answer
| Keep wkhtmltopdf if | Move to Puppeteer if |
|---|---|
| Your templates are table-and-float HTML that renders correctly today | Your templates use flexbox, CSS Grid, custom properties or web fonts |
| You only ever convert HTML you wrote, never user input | Any part of the HTML or a URL comes from a user |
| You run on a host where you cannot install Chromium | You can run Node and a Chromium binary (or call an API that does) |
| A single static binary per server is a hard requirement | You need JavaScript to run before the PDF is printed (charts, client-side data) |
| You have no time to re-test every template this quarter | You want an engine that still receives security fixes |
The left column is a holding pattern, not a recommendation. Plan the move even if you cannot do it this month.
What changed since you chose wkhtmltopdf
The project's status page, written by its long-time maintainer, is unusually direct:
- The Qt 4 that wkhtmltopdf uses has not been supported since 2015, and the WebKit in it has not been updated since 2012.
- The repository was archived on January 2, 2023, so there will be no new releases.
- The recommendations: do not use it with untrusted HTML ("it can lead to complete takeover of the server"), and "if you're using it to convert a site which uses dynamic JS, consider using puppeteer".
The engine age is what you feel day to day. A 2012 WebKit predates CSS Grid (2017 in browsers), the unprefixed flexbox syntax and CSS custom properties, and its JavaScript engine predates ES2015 (let, const, arrow functions, class, Promises). Every rule your front-end team writes today assumes those exist.
The test template
We used one invoice template for both tools, with the kind of CSS a team writes in 2026 (the full file is 40 lines):
<style> :root { --accent: #4f46e5 } @page { size: A4; margin: 16mm } header { display: flex; justify-content: space-between; border-bottom: 3px solid var(--accent) } .meta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px } thead { display: table-header-group } /* repeat the table header on each page */ th { background: var(--accent); color: #fff } tr { break-inside: avoid } </style> <!-- 80 table rows are added by a small inline script -->
On wkhtmltopdf, expect this template to fail in four places. The header will stack instead of placing the logo and address side by side, because display: flex with justify-content is the 2015 syntax. The .meta grid will become three full-width blocks. Every var(--accent) will resolve to nothing, so the table header text is white on a white background, which is invisible. And the inline script uses a template literal, which a pre-ES2015 engine rejects, so the table body stays empty. That last failure is silent: you get a valid PDF with no line items.
The fixes for a wkhtmltopdf-only template are old techniques: a two-cell table instead of flex, floats instead of grid, literal colours instead of variables, ES5 var and string concatenation in scripts. If you must keep both engines for a while, write the old technique first and let modern CSS override it:
/* Old technique first. wkhtmltopdf's 2012 WebKit does not know @supports, so it skips the whole block below and keeps the floats. */ .meta div { float: left; width: 31%; margin-right: 2% } @supports (display: grid) { .meta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px } .meta div { float: none; width: auto; margin-right: 0 } } /* An engine without var() drops the second declaration and keeps the first. */ th { background: #4f46e5; background: var(--accent) }
Render this block in both engines before you trust it. This is why dual-engine templates are a short bridge, not a destination.
Puppeteer: tested code and measured timings
// puppeteer-invoice.mjs: HTML invoice to A4 PDF with Puppeteer. // npm i puppeteer@24 import puppeteer from 'puppeteer'; const url = process.argv[2] ?? 'http://127.0.0.1:8765/invoice.html'; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); // 'load' is enough for server-rendered HTML; use 'networkidle0' only when // the page fetches data after load (it adds at least 500 ms of idle time). await page.goto(url, { waitUntil: 'load', timeout: 30_000 }); await page.emulateMediaType('print'); await page.pdf({ path: 'invoice.pdf', format: 'A4', printBackground: true, // keep table header colours preferCSSPageSize: true, // let @page { size; margin } win displayHeaderFooter: true, headerTemplate: '<span></span>', footerTemplate: '<div style="font-size:9px;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>', }); console.log('wrote invoice.pdf'); } finally { await browser.close(); }
Tested on October 15, 2026 with Node 22.18.0 and [email protected] (Chrome for Testing 141.0.7390.122) on macOS 27.0.1, Apple M1 Pro. Output: a 3-page A4 PDF, 214,902 bytes with the footer.
printBackground: true is the setting people forget. Without it Chromium drops background colours, so a styled table header prints as white text on white, which is the same symptom wkhtmltopdf gives you for var().
Measured timings. One browser launched once, a new page per PDF, 10 runs per template, pages served from localhost so the network is not part of the number. The time covers goto plus pdf().
| Template | Wait | p50 | p95 | PDF bytes | Pages |
|---|---|---|---|---|---|
| example.com (mirrored) | load |
115 ms | 131 ms | 20,929 | 1 |
| CSS features page | load |
171 ms | 228 ms | 123,018 | 1 |
| Invoice, 80 rows | load |
193 ms | 221 ms | 207,384 | 3 |
| example.com (mirrored) | networkidle0 |
1,078 ms | 1,092 ms | 20,929 | 1 |
| CSS features page | networkidle0 |
1,127 ms | 1,144 ms | 123,018 | 1 |
| Invoice, 80 rows | networkidle0 |
1,141 ms | 1,183 ms | 207,384 | 3 |
Method: Puppeteer 24.26.1, page.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true }) without header and footer templates, Apple M1 Pro with 32 GB of memory, October 15, 2026. With 10 runs, p95 is the highest or second-highest sample, so read it as "worst case seen".
The table makes one point clearly: the wait condition costs more than the rendering. networkidle0 waits until there have been no network connections for 500 ms, and on these pages it added about 900 ms to every PDF. If your HTML is rendered on the server, load is enough. If a script fetches data after load, wait for a selector that only exists once the data is in the DOM (page.waitForSelector('.total')) instead of waiting for the network to go quiet.
Browser launch is the other cost, and it is why "wkhtmltopdf is faster" was true for scripts that started a new Chromium per document. Keep one browser open and open a page per request, as the timing harness did. For the full production pattern (page pools, timeouts, zombie processes), see scaling Puppeteer and the Puppeteer HTML to PDF tutorial.
wkhtmltopdf: the equivalent command
wkhtmltopdf \ --page-size A4 \ --margin-top 16mm --margin-bottom 16mm --margin-left 16mm --margin-right 16mm \ --print-media-type \ --footer-center "Page [page] of [topage]" --footer-font-size 8 \ --javascript-delay 200 \ invoice.html invoice.pdf
Not run for this page. The official wkhtmltopdf builds for macOS are x86-only, and our Apple-silicon test machine has no x86 translation layer installed. We did not want to publish numbers from a different machine next to the Puppeteer table. To run it yourself in a container:
docker run --rm -v "$PWD:/work" -w /work debian:bookworm-slim sh -c \ 'apt-get update -qq && apt-get install -y -qq wkhtmltopdf >/dev/null && \ wkhtmltopdf --page-size A4 --print-media-type invoice.html invoice.pdf'
Two caveats. Distribution packages are often built against an unpatched Qt, which wkhtmltopdf reports as "reduced functionality": headers, footers and some page options do not work there, so the --footer-center line above needs the static build from the project's packaging releases. And since 0.12.6, local file access is blocked by default, so templates that load CSS or images from disk need --enable-local-file-access (keep that flag off for any HTML you did not write).
What to measure when you run it: the same three templates, 10 runs each, wall time per process (wkhtmltopdf has no warm mode, so every run includes start-up), output bytes, page count, and a visual check of the four failure points listed above.
Side by side
| wkhtmltopdf 0.12.6 | Puppeteer 24 (Chromium) | |
|---|---|---|
| Engine | QtWebKit, WebKit from about 2012 | Current Chromium (Chrome for Testing 141 in our run) |
| Maintained | Archived January 2, 2023 | Active releases |
CSS Grid, standard flexbox, var() |
No | Yes |
| JavaScript | Pre-ES2015 | Current |
| Install | One binary (plus fonts) | Node package plus a browser download |
| Warm process reuse | No, one process per document | Yes, one browser, many pages |
| Header and footer | --header-* / --footer-* flags, or HTML files |
headerTemplate / footerTemplate HTML |
| Page numbers | [page], [topage] |
<span class="pageNumber">, <span class="totalPages"> |
| Table of contents, outline | Built in (patched Qt) | Not built in; tagged PDF outline via outline: true in recent versions |
| Untrusted HTML | Maintainers say do not | Sandbox the browser, block internal networks |
The table-of-contents row is the one real feature you may lose. If your documents rely on wkhtmltopdf's generated TOC, plan to build it in HTML before printing, or look at WeasyPrint and Chrome compared for print-first engines.
Migrating templates, in order
- Inventory. List every template and every flag your code passes to wkhtmltopdf. Flags map to
page.pdf()options almost one to one, which the wkhtmltopdf maintainer also noted. - Render both and diff. Produce each template with both tools from the same data and compare page counts first. A changed page count means changed page breaks, which customers notice.
- Delete the workarounds. Old templates often contain
-webkit-boxand float hacks written for wkhtmltopdf. Chromium renders most of them, but they make page breaks harder to reason about. - Set print CSS deliberately. Use
@page { size; margin }withpreferCSSPageSize: true,break-inside: avoidon rows and cards, andthead { display: table-header-group }to repeat table headers. - Fix the wait. Replace
--javascript-delayand--window-statuswithwaitUntil: 'load'plus awaitForSelectorfor client-rendered parts.
If you would rather not run Chromium
Running Puppeteer means owning Chromium on your servers: memory per browser, fonts for every language your customers write in, timeouts and crash recovery. If that is the part you want to drop, the same invoice from RenderScreenshot is one request:
// rs-pdf.mjs: the same invoice PDF from RenderScreenshot (Chromium, managed). // Node 22+, npm i renderscreenshot import { Client, TakeOptions } from 'renderscreenshot'; import { writeFile } from 'node:fs/promises'; const client = new Client(process.env.RS_API_KEY); try { const pdf = await client.take( TakeOptions.url(process.argv[2] ?? 'https://example.com/invoice/2026-1015') .format('pdf') .pdfPaperSize('a4') .pdfPrintBackground() .pdfMargin('16mm') .pdfFooter('<div style="font-size:9px;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>') ); await writeFile('invoice.pdf', pdf); console.log(`saved ${pdf.length} bytes`); } catch (err) { console.error(err.message); process.exitCode = 1; }
Tested on October 15, 2026 with Node 22.18.0 and [email protected] against a local mock of the API (request shape and error handling), not against production.
The PDF options are documented on the PDF parameters page. Because the renderer is Chromium, the CSS rules from the Puppeteer section apply unchanged. You can create a free key and render your own worst template before you decide whether to self-host.