wkhtmltopdf was the go-to HTML-to-PDF tool for years. Thousands of apps use it. But it has a serious problem: it's been effectively abandoned since 2020. The last proper release is v0.12.6, QtWebKit (its rendering engine) is frozen at Chromium 72, and the maintainer has stated they don't have bandwidth to update it.
The result: modern CSS like Grid, Flexbox gap, clamp(), CSS custom properties, and @supports queries are broken or unsupported. Sites with modern JavaScript frameworks — React, Vue, Next.js — often render as blank pages or broken layouts. If your invoice, report, or export looks fine on screen but wrong as a PDF, this is why.
In 2026 there are much better options. Here's an honest breakdown.
Why Replace wkhtmltopdf
- No security patches — QtWebKit 5.15 (what wkhtmltopdf uses) hasn't received CVE patches in years. Feeding it untrusted HTML is a risk.
- CSS support frozen at 2018 — No CSS Grid gap shorthand, no
clamp(), noaspect-ratio, no custom properties in some contexts. - JavaScript execution is broken — Complex React/Vue/Angular apps frequently render empty or with errors.
- Fonts and emoji — Missing system fonts on servers cause character fallback issues.
- Cloud/serverless — wkhtmltopdf requires system-level installation and doesn't work well in containers without significant setup.
- No maintenance — Open issues on GitHub go unanswered. The project is effectively done.
Option 1: Puppeteer (Node.js)
Google's official Node.js library for controlling Chromium. Uses the real Chromium engine — full CSS and JS support, pixel-perfect rendering.
Pros
- Real Chromium — modern CSS works
- Full JavaScript support
- Free and open source
- Large community
Cons
- ~300MB Chromium binary per install
- High memory per PDF (~150-200MB)
- Cold start on serverless (3-8s)
- You manage concurrency yourself
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.goto('https://yourapp.com/invoice/123', {
waitUntil: 'networkidle0'
});
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
});
await browser.close();
// pdf is a Buffer — write to file or return as response
Works great for prototypes and simple apps. The problems emerge at scale: running 10+ concurrent Puppeteer instances is a memory and stability nightmare. Each instance holds Chromium in memory, and crashes cascade. You end up building a browser pool, a queue, health checks, and memory watchdog scripts — which is essentially re-inventing a screenshot API infrastructure.
Option 2: Playwright (Node.js, Python, .NET, Java)
Microsoft's browser automation library. More modern API than Puppeteer, supports Chromium/Firefox/WebKit, multi-language. Generally the best self-hosted option in 2026.
Pros
- Better API than Puppeteer
- Multi-language (Node, Python, .NET, Java)
- Better async/await model
- Actively maintained by Microsoft
Cons
- Same memory footprint as Puppeteer
- Same serverless cold-start pain
- You still manage concurrency
- ~600MB browser binaries (all three)
from playwright.async_api import async_playwright
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto('https://yourapp.com/invoice/123')
await page.wait_for_load_state('networkidle')
await page.pdf(
path='invoice.pdf',
format='A4',
print_background=True,
margin={'top': '20mm', 'bottom': '20mm', 'left': '15mm', 'right': '15mm'}
)
await browser.close()
Playwright is the best self-hosted option — but "best self-hosted" still means you're running a browser process, managing memory, handling crashes, and scaling horizontally when load spikes. If you're generating more than ~100 PDFs/day, the operational overhead starts to matter.
Option 3: Grover (Ruby)
A Ruby gem that wraps Puppeteer/Chrome for PDF and screenshot generation. Popular Rails replacement for PDFKit (which wraps wkhtmltopdf).
Pros
- Drop-in for Rails apps using PDFKit
- Real Chromium rendering
- Simple API
Cons
- Node.js + Puppeteer required alongside Ruby
- Two runtimes to maintain
- Same memory/stability issues
# Gemfile
gem 'grover'
# Usage
grover = Grover.new('https://yourapp.com/invoice/123')
pdf = grover.to_pdf
# Returns a binary string
# Or from HTML string
grover = Grover.new('Hello
', display_url: 'http://yourapp.com')
pdf = grover.to_pdf
Grover is the easiest migration path for Rails apps currently using PDFKit. The tradeoff is that you're now shipping Node.js alongside your Ruby app, which adds deployment complexity — especially on platforms like Heroku, Render, or Fly.io where you need buildpacks or custom Dockerfiles for both runtimes.
Option 4: PDF API (No Browser on Your Server)
Send a URL or HTML to an API endpoint, get a PDF back. No Chromium binary on your server, no memory management, no cold starts. Works from any language with one HTTP call.
Pros
- Zero server-side Chromium
- No concurrency to manage
- Works from any language
- 200 free PDFs/month
Cons
- Requires network call (~200-600ms)
- Not free at high volume
- URL must be publicly accessible
// Generate PDF from a live URL
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: { 'X-Api-Key': 'sk_live_xxx', 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://yourapp.com/invoice/123?token=abc',
format: 'pdf',
pdf_format: 'A4',
pdf_print_background: true,
pdf_margin_top: '20mm',
pdf_margin_bottom: '20mm'
})
});
const { url: pdfUrl } = await res.json();
// pdfUrl is a CDN link, download it or redirect the user to it
# Python equivalent
import httpx
response = httpx.post(
'https://api.snapapi.pics/v1/screenshot',
headers={'X-Api-Key': 'sk_live_xxx'},
json={
'url': 'https://yourapp.com/invoice/123',
'format': 'pdf',
'pdf_format': 'A4',
'pdf_print_background': True,
}
)
pdf_url = response.json()['url']
# Ruby equivalent
require 'net/http'
require 'json'
uri = URI('https://api.snapapi.pics/v1/screenshot')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Post.new(uri, {
'X-Api-Key' => 'sk_live_xxx',
'Content-Type' => 'application/json'
})
req.body = { url: 'https://yourapp.com/invoice/123', format: 'pdf', pdf_format: 'A4' }.to_json
res = http.request(req)
pdf_url = JSON.parse(res.body)['url']
This is the fastest migration path if you want zero infrastructure overhead. Your server makes one HTTP call, gets a PDF URL back, and you're done. No browser binary, no memory leak, no cold start. Scales automatically — whether you're generating 10 PDFs/day or 50,000.
Comparison Table
| Option | Setup | Modern CSS | JS rendering | Serverless | Memory | Cost |
|---|---|---|---|---|---|---|
| wkhtmltopdf | System binary | Broken | Limited | Hard | Low | Free |
| Puppeteer | npm install | ✓ | ✓ | Complex | 150-200MB/browser | Free + infra |
| Playwright | npm install | ✓ | ✓ | Complex | 150-200MB/browser | Free + infra |
| Grover | gem + Node.js | ✓ | ✓ | Complex | 150-200MB/browser | Free + infra |
| SnapAPI | HTTP call | ✓ | ✓ | ✓ | Zero (their infra) | Free tier → $19/mo |
Which Should You Pick?
Migrating a Rails/Django/Laravel app? If you want to stay self-hosted, go with Grover (Rails), Playwright (Python/Django), or the equivalent wrapper for your framework. Expect to spend 2-4 hours getting a browser binary running in your deployment environment.
Running on serverless (Lambda, Vercel, Fly.io)? Self-hosted Chromium is painful — large layers, cold starts, memory limits. A PDF API is the pragmatic choice. One HTTP call, no deployment complexity.
Generating more than 1,000 PDFs/month? Run the math. EC2 time, memory overhead, ops time managing crashes — a PDF API often costs less than the server time plus engineering hours. SnapAPI's Pro plan at $79/mo covers 50,000 calls.
Need full control or airgapped deployment? Playwright is your best bet. It's actively maintained, multi-language, and the most feature-complete self-hosted option available.
Replace wkhtmltopdf with one HTTP call
200 free PDFs/month, no credit card. Modern CSS, full JS rendering, works from any language.
Get your free API key →