wkhtmltopdf Alternative: Tested Migration to Chrome

wkhtmltopdf is archived. We ran two builds on modern CSS, found flags a distro build ignores, and wrote drop-in shims for pdfkit, WickedPDF and Snappy.

By Simon Chiu · Updated

Disclosure: RenderScreenshot is our product, and it is one of the replacements on this page. wkhtmltopdf is free software. The test results below come from our own runs on 2026-10-18; the method is at the end of the test section.

wkhtmltopdf is finished. Its GitHub repository was archived on January 2, 2023, and the project's status page says its Qt 4 base has been unsupported since 2015 and its WebKit has not been updated since 2012. The same page warns against using it with untrusted HTML. If your app still prints invoices through pdfkit, WickedPDF or KnpSnappy, the question is not whether to move but what to move to, and how to do it without rewriting every call site.

This page answers both. We ran two wkhtmltopdf builds and Chrome on the same three templates, mapped every common flag to its Chrome equivalent, and wrote shims for Python, Ruby and PHP that keep your existing calls. For the other options in the market, see our HTML to PDF API roundup.

The short answer

Your situation Move to
A few PDFs a day, you already run Node or Python workers Self-hosted Chrome through Puppeteer or Playwright
Back-office documents, you want a container and no browser code Gotenberg (Chrome in Docker, MIT licence)
You do not want to run Chrome at all, or you also need screenshots A hosted API such as RenderScreenshot's PDF API
Your templates use only old CSS, and the PDFs never see user input Staying is possible, but read the security warning first

Our wkhtmltopdf vs Puppeteer comparison goes deeper into the self-hosted route. This page focuses on the switch itself.

What we tested

We built three templates that look like real work: a 60-row A4 invoice with a repeating table header and a total box, a layout page with nine modern CSS features, and a five-chapter report that uses break-before: page and CSS page counters (@page { @bottom-center { content: counter(page) } }). Each template has hidden marker strings, so pdftotext can prove that text was not lost.

We printed them with three engines:

  • wkhtmltopdf 0.12.6.1 "with patched qt", the official build from the project's packaging releases, in a Debian 12 arm64 container.
  • wkhtmltopdf 0.12.6 from Debian 12's own package (apt install wkhtmltopdf), which is built against unpatched Qt.
  • Chrome 148 through Puppeteer 24.43.1 page.pdf() on macOS.

Modern CSS

Feature Official build (patched Qt) Debian package (unpatched Qt) Chrome 148
display: flex with gap Items stacked vertically Row laid out, gap ignored Correct
CSS grid with a spanning cell Stacked into one column Stacked into one column Correct
Custom properties (var(--brand)) Ignored: white text on a white box Applied Applied
clamp() font size Ignored Ignored Applied
aspect-ratio Ignored Ignored Applied
object-fit: cover Image stretched Image stretched Image cropped
:has(), CSS nesting, color-mix() Ignored Ignored Applied

Every marker string was present in every PDF. That is the dangerous part: nothing fails, the text is all there, and only a person who opens the file sees that the layout is wrong. The custom-property row is the worst case. Our flex boxes used background: var(--brand) with white text, so in the official build the text was in the PDF but invisible.

Paged media and output

Official build Debian package Chrome 148
Invoice pages (A4, defaults) 2 2 3
Report pages (cover + 5 chapters) 3: break-before: page ignored 6 6
CSS @page page counters printed 0 of 6 pages 0 of 6 pages 5 of 6 pages (first page suppressed on purpose)
Invoice file size 25,318 bytes 25,294 bytes 111,438 bytes
Warm render, median of 5 278 ms 274 ms 371 ms

Two notes on that table. wkhtmltopdf files were four times smaller in our test, and it was not slower; speed and size are not reasons to leave it. The invoice is shorter in wkhtmltopdf because of "smart shrinking", which scales content down by default. Chrome prints at true size, so expect more pages after the switch until you tune scale and margins. The timings came from different environments (wkhtmltopdf in a Linux container, Chrome on macOS), so read them as "the same order of magnitude", not as a race.

The flags your distro build silently drops

This was the finding we did not expect. With Debian's package, this command:

wkhtmltopdf --page-size A4 --margin-bottom 20mm --print-media-type \
  --disable-smart-shrinking --footer-center "Page [page] of [topage]" invoice.html out.pdf

printed The switch --footer-center, is not support using unpatched qt, and will be ignored (and the same for the other two switches), then exited with code 0. The PDF had no footer. The official build printed "Page N of 3" on all three pages. If page numbers vanished after an OS upgrade or a Docker base-image change, run wkhtmltopdf --version: only builds that say "with patched qt" support headers, footers, --print-media-type and the table of contents.

Flag map: wkhtmltopdf to Chrome

The left column is the wkhtmltopdf command-line flag (pdfkit and Snappy use the same names without dashes; WickedPDF uses snake_case keys). The right column is the RenderScreenshot PDF parameter.

wkhtmltopdf RenderScreenshot Notes
--page-size A4 pdf.paper: "a4" Our default is letter, theirs is A4. Set it. Only a0 to a6, letter, legal and tabloid are accepted
--page-width, --page-height pdf.width, pdf.height CSS units as strings: "210mm"
--orientation Landscape pdf.landscape: true
-T, -R, -B, -L pdf.margin_top and the other sides Their default is 10 mm on every side; ours is 0
--zoom 0.8 pdf.scale: 0.8 Range 0.1 to 2.0
--disable-smart-shrinking nothing to do Chrome never shrinks on its own; pdf.fit_one_page is the opt-in version
--no-background pdf.print_background: false Backgrounds are on by default in both
--print-media-type browser.media: "print"
--footer-center "[page] of [topage]" pdf.footer with <span class='pageNumber'> and <span class='totalPages'> HTML template, styled inline
--header-html header.html pdf.header (the HTML as a string) Images inside must be data: URIs
[date], [title], [webpage] classes date, title, url
--javascript-delay 500 wait.delay: 500 Better: wait.until: "networkidle" or wait.for_selector
--window-status ready wait.for_selector Add an element when the page is ready, and wait for it
--cookie name value network.cookies
--custom-header Name value network.headers Sent only to the target origin
--username, --password network.auth with type: "basic"
--toc, --outline none No generated table of contents or PDF outline
--enable-local-file-access none Send the HTML itself, and set html_base_url for its assets

The last row is the one most migrations trip on. wkhtmltopdf reads file:// paths from your disk. A hosted renderer cannot, and it also refuses private addresses such as localhost. Inline small assets as data: URIs, or put them on a public URL and set html_base_url (content input).

Drop-in shims

Each shim keeps the library's method names and option keys, translates them with the table above, and sends one request. They keep wkhtmltopdf's A4 paper and 10 mm margins as defaults, so the first PDFs after the switch are close to what you had. We ran each shim's option translation in a unit check on 2026-10-18 (Python 3.15, Ruby 4.0.7, PHP 8.5.10); the HTTP call itself was not run for this page. Every shim raises on a non-2xx response.

Python: replaces pdfkit

"""Drop-in replacement for pdfkit.from_string / pdfkit.from_url that renders with RenderScreenshot."""
import json
import os
import re
import urllib.request

API = 'https://api.renderscreenshot.com/v1/screenshot'
TOKENS = {'[page]': 'pageNumber', '[topage]': 'totalPages', '[date]': 'date', '[title]': 'title', '[webpage]': 'url'}


def _template(text, size):
    """Turn wkhtmltopdf's '[page] of [topage]' text into a Chrome header/footer template."""
    escaped = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
    filled = re.sub(r'\[(page|topage|date|title|webpage)\]',
                    lambda m: f"<span class='{TOKENS[m.group(0)]}'></span>", escaped)
    return f"<div style='font-size:{size}pt;width:100%;text-align:center'>{filled}</div>"


def build_body(source, options=None, is_html=True):
    opts = {k.lstrip('-'): v for k, v in (options or {}).items()}
    pdf = {
        'paper': str(opts.get('page-size', 'A4')).lower(),   # wkhtmltopdf defaults to A4
        'print_background': 'no-background' not in opts,
        'margin_top': opts.get('margin-top', '10mm'),          # wkhtmltopdf defaults to 10mm
        'margin_right': opts.get('margin-right', '10mm'),
        'margin_bottom': opts.get('margin-bottom', '10mm'),
        'margin_left': opts.get('margin-left', '10mm'),
    }
    if str(opts.get('orientation', '')).lower() == 'landscape':
        pdf['landscape'] = True
    if 'zoom' in opts:
        pdf['scale'] = float(opts['zoom'])
    size = opts.get('footer-font-size') or opts.get('header-font-size') or 9
    if 'footer-center' in opts:
        pdf['footer'] = _template(opts['footer-center'], size)
        pdf.setdefault('header', '<span></span>')
    if 'header-center' in opts:
        pdf['header'] = _template(opts['header-center'], size)
    body = {'html' if is_html else 'url': source, 'output': {'format': 'pdf'}, 'pdf': pdf}
    if 'print-media-type' in opts:
        body['browser'] = {'media': 'print'}
    if 'javascript-delay' in opts:
        body['wait'] = {'delay': int(opts['javascript-delay'])}
    return body


def _render(body, output_path):
    req = urllib.request.Request(API, data=json.dumps(body).encode(), method='POST', headers={
        'Authorization': f"Bearer {os.environ['RS_API_KEY']}", 'Content-Type': 'application/json'})
    with urllib.request.urlopen(req, timeout=90) as resp:   # raises HTTPError on 4xx/5xx
        pdf = resp.read()
    if output_path:
        with open(output_path, 'wb') as f:
            f.write(pdf)
        return True
    return pdf


def from_string(html, output_path=False, options=None, **_ignored):
    return _render(build_body(html, options, is_html=True), output_path)


def from_url(url, output_path=False, options=None, **_ignored):
    return _render(build_body(url, options, is_html=False), output_path)

Save it as rs_pdfkit.py and change one line: import rs_pdfkit as pdfkit. Calls such as pdfkit.from_string(html, False, options={'page-size': 'Letter', 'footer-center': 'Page [page] of [topage]'}) keep working. The configuration= argument is accepted and ignored, because there is no binary to locate any more. To move to WeasyPrint or Playwright instead, see HTML to PDF in Python.

Ruby: replaces WickedPdf.new

# Replacement for WickedPdf.new.pdf_from_string / pdf_from_url that renders with RenderScreenshot.
require 'json'
require 'net/http'
require 'cgi'

class RsWickedPdf
  API = URI('https://api.renderscreenshot.com/v1/screenshot')
  TOKENS = { '[page]' => 'pageNumber', '[topage]' => 'totalPages', '[date]' => 'date',
             '[title]' => 'title', '[webpage]' => 'url' }.freeze

  def pdf_from_string(html, options = {})
    render(build_body({ html: html }, options))
  end

  def pdf_from_url(url, options = {})
    render(build_body({ url: url }, options))
  end

  def build_body(source, options)
    margin = options.fetch(:margin, {})
    mm = ->(side) { "#{margin.fetch(side, 10)}mm" } # wicked_pdf margins are numbers in mm
    pdf = {
      paper: options.fetch(:page_size, 'A4').to_s.downcase,
      landscape: options[:orientation].to_s.casecmp?('landscape'),
      print_background: !options[:no_background],
      margin_top: mm.call(:top), margin_right: mm.call(:right),
      margin_bottom: mm.call(:bottom), margin_left: mm.call(:left)
    }
    pdf[:scale] = options[:zoom].to_f if options[:zoom]
    if (footer = options.dig(:footer, :center))
      pdf[:footer] = template(footer, options.dig(:footer, :font_size) || 9)
      pdf[:header] = '<span></span>'
    end
    if (header = options.dig(:header, :center))
      pdf[:header] = template(header, options.dig(:header, :font_size) || 9)
    end
    body = source.merge(output: { format: 'pdf' }, pdf: pdf)
    body[:browser] = { media: 'print' } if options[:print_media_type]
    body[:wait] = { delay: options[:javascript_delay].to_i } if options[:javascript_delay]
    body
  end

  private

  def template(text, size)
    filled = CGI.escapeHTML(text).gsub(/\[(page|topage|date|title|webpage)\]/) do |token|
      "<span class='#{TOKENS[token]}'></span>"
    end
    "<div style='font-size:#{size}pt;width:100%;text-align:center'>#{filled}</div>"
  end

  def render(body)
    request = Net::HTTP::Post.new(API, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}",
                                       'Content-Type' => 'application/json')
    request.body = body.to_json
    response = Net::HTTP.start(API.host, API.port, use_ssl: true, read_timeout: 90) { |http| http.request(request) }
    raise "RenderScreenshot #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

    response.body
  end
end

In a Rails app, replace WickedPdf.new.pdf_from_string(render_to_string(...), options) with RsWickedPdf.new.pdf_from_string(...). Controller actions that use render pdf: go through WickedPDF's own renderer, so change those to render the template to a string first, then call the shim. The RenderScreenshot Ruby SDK is the other route if you prefer a builder over raw JSON. Rails HTML to PDF compares Grover and Prawn as well.

PHP: replaces Knp\Snappy\Pdf

<?php
// Replacement for Knp\Snappy\Pdf::getOutputFromHtml() / getOutput() that renders with RenderScreenshot.
final class RsSnappyPdf
{
    private const API = 'https://api.renderscreenshot.com/v1/screenshot';
    private const TOKENS = ['page' => 'pageNumber', 'topage' => 'totalPages', 'date' => 'date',
                            'title' => 'title', 'webpage' => 'url'];
    private array $defaults = [];

    public function setOption(string $name, mixed $value): void { $this->defaults[$name] = $value; }

    public function getOutputFromHtml(string $html, array $options = []): string
    {
        return $this->render($this->buildBody(['html' => $html], $options));
    }

    public function getOutput(string $url, array $options = []): string
    {
        return $this->render($this->buildBody(['url' => $url], $options));
    }

    public function buildBody(array $source, array $options): array
    {
        $o = array_merge($this->defaults, $options);
        $pdf = [
            'paper' => strtolower($o['page-size'] ?? 'A4'),         // wkhtmltopdf default
            'landscape' => strcasecmp($o['orientation'] ?? '', 'landscape') === 0,
            'print_background' => empty($o['no-background']),
            'margin_top' => $o['margin-top'] ?? '10mm',              // wkhtmltopdf default
            'margin_right' => $o['margin-right'] ?? '10mm',
            'margin_bottom' => $o['margin-bottom'] ?? '10mm',
            'margin_left' => $o['margin-left'] ?? '10mm',
        ];
        if (isset($o['zoom'])) { $pdf['scale'] = (float) $o['zoom']; }
        if (isset($o['footer-center'])) {
            $pdf['footer'] = $this->template($o['footer-center'], $o['footer-font-size'] ?? 9);
            $pdf['header'] = '<span></span>';
        }
        if (isset($o['header-center'])) {
            $pdf['header'] = $this->template($o['header-center'], $o['header-font-size'] ?? 9);
        }
        $body = $source + ['output' => ['format' => 'pdf'], 'pdf' => $pdf];
        if (!empty($o['print-media-type'])) { $body['browser'] = ['media' => 'print']; }
        if (isset($o['javascript-delay'])) { $body['wait'] = ['delay' => (int) $o['javascript-delay']]; }
        return $body;
    }

    private function template(string $text, int|string $size): string
    {
        $filled = preg_replace_callback('/\[(page|topage|date|title|webpage)\]/',
            fn ($m) => "<span class='" . self::TOKENS[$m[1]] . "'></span>",
            htmlspecialchars($text, ENT_QUOTES));
        return "<div style='font-size:{$size}pt;width:100%;text-align:center'>{$filled}</div>";
    }

    private function render(array $body): string
    {
        $ch = curl_init(self::API);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 90,
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('RS_API_KEY'), 'Content-Type: application/json'],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
        ]);
        $pdf = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        if ($pdf === false || $status !== 200) {
            throw new RuntimeException("RenderScreenshot {$status}: " . ($pdf ?: curl_error($ch)));
        }
        return $pdf;
    }
}

In Laravel, bind RsSnappyPdf where you resolved Knp\Snappy\Pdf (for example in a service provider). Code that calls generateFromHtml($html, $path) to write a file needs one extra line: file_put_contents($path, $snappy->getOutputFromHtml($html)). Laravel HTML to PDF compares this route with dompdf and Browsershot.

Roll it out without a big bang

  1. Render both for a week. Keep wkhtmltopdf as the source of truth, and send the same HTML through the shim in a background job. Store both files.
  2. Compare text, then pages. Run pdftotext on both and diff the words; any missing word is a template bug. Then compare page counts. Big jumps point at smart shrinking (tune pdf.scale) or at a font that is not installed.
  3. Fix the CSS you no longer need. Old -webkit-box hacks and table-based layouts that you added for wkhtmltopdf can now be flexbox or grid.
  4. Switch per document type. Move receipts first, then invoices, then the long reports with headers and footers.
  5. Remove the binary. Delete the package from your images, and with it the security warning about untrusted HTML.

Cost at three volumes

wkhtmltopdf has no licence cost; you pay in servers and maintenance. RenderScreenshot charges one credit per successful PDF of any length, with no overage (prices from our pricing page, October 2026):

PDFs per month RenderScreenshot
1,000 Starter, $10 a month
10,000 Growth, $40 a month
100,000 Above our largest plan (60,000 credits); not a self-serve fit today

At 100,000 PDFs a month, a self-hosted Chrome or Gotenberg container is the cheaper path, and the flag map above applies to it just as well.

When to keep wkhtmltopdf

  • Air-gapped servers with fixed templates. If the HTML is yours, never contains user input, uses no modern CSS and never leaves a private network, the risk is contained.
  • You need --toc or a PDF outline. Chrome's print engine does not generate either. A print engine such as Prince does.
  • File size matters more than fidelity. Our wkhtmltopdf files were about four times smaller. Check whether that still holds for your documents after image compression.

For everything else, get a free API key and run the shim next to your current setup for a week. The PDF API overview lists the header, footer and print-CSS rules that will save you most of the tuning.

Last tested 2026-10-18: wkhtmltopdf 0.12.6.1 (with patched qt) from the wkhtmltopdf packaging releases and wkhtmltopdf 0.12.6-2+b1 from Debian 12, both arm64 in Docker on an Apple M1 Pro; Chrome 148.0.7778.97 through Puppeteer 24.43.1 on Node 22.18.0, macOS. One cold and five warm runs per template.

Frequently asked questions

Is wkhtmltopdf deprecated?
Yes, in practice. Its GitHub repository was archived on January 2, 2023 and is read-only. The project's status page says its Qt 4 base has been unsupported since 2015 and its WebKit has not been updated since 2012.
Why are my wkhtmltopdf header and footer options ignored?
You probably have a build against unpatched Qt, which is what Linux distribution packages ship. In our Debian 12 test it printed 'is not support using unpatched qt, and will be ignored' for --footer-center, --print-media-type and --disable-smart-shrinking, then exited with code 0. Check with wkhtmltopdf --version: the official builds say 'with patched qt'.
What is the closest replacement for wkhtmltopdf?
Headless Chrome. You can run it yourself with Puppeteer or Playwright, run it in a container such as Gotenberg, or call a hosted API. All of them print with Chrome's engine, so flexbox, grid and modern CSS work.
Will my PDFs look the same after the switch?
Not exactly. wkhtmltopdf shrinks content to fit by default and uses 10 mm margins, so Chrome output is larger and runs to more pages until you set margins and scale. Our invoice went from 2 pages to 3.
Is wkhtmltopdf safe for user-supplied HTML?
The maintainers say not to use it with untrusted HTML, because it can lead to a complete takeover of the server.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.