Rails HTML to PDF: wicked_pdf vs Grover vs Prawn (2026)
Generate PDFs in Rails 8: wicked_pdf, Grover, Prawn or an API. Measured speed and size on one invoice, Active Storage code, and when each choice fits.
Rails has no built-in PDF renderer, so you pick a gem. There are four realistic choices in 2026, and they work in different ways:
- wicked_pdf renders a Rails view to HTML and pipes it through the
wkhtmltopdfbinary. - Grover renders a Rails view to HTML and prints it with Puppeteer and headless Chrome.
- Prawn does not use HTML at all. You draw the document in Ruby.
- An HTTP API takes the same HTML and returns the PDF, so the server runs no browser.
We printed one 60-line A4 invoice with Grover and Prawn, compared the result with the same HTML printed by other engines, and wrote the Active Storage wiring for a Rails 8 app. This page is part of our HTML to PDF API cluster. If you only need screenshots and a PDF now and then, the Rails screenshot API guide is shorter.
Tested on 2026-10-18 on macOS (Apple Silicon), Ruby 4.0.7.
grover 1.2.10withpuppeteer 24.43.1on Node.js 22.18.0.prawn 2.5.0andprawn-table 0.2.2. wicked_pdf was not run:wkhtmltopdfis not installed on our test machine, and the project is archived. Timings are the median of five calls in one Ruby process. Page counts and sizes come frompdfinfo.
Results on one invoice
The invoice has a flex header, a CSS grid address block, a CSS variable for the brand color, a rounded status badge, a 60-row table with a repeating header row, and a "Terms" section forced onto a new page.
| Approach | Input | Pages | Bytes | Median time per PDF | Runs a browser? |
|---|---|---|---|---|---|
| Grover (Chrome 148) | HTML/CSS | 3 | 118,514 | 2,955 ms | Yes, a new Node and Chrome process per call |
| Prawn + prawn-table | Ruby drawing code | 3 | 43,592 | 80 ms | No |
| wicked_pdf | HTML/CSS | not run | not run | not run | Calls a wkhtmltopdf process |
Puppeteer page.pdf() on a warm page (reference) |
HTML/CSS | 3 | 118,514 | 110 ms | Yes, reused |
Three things stand out:
- Grover's cost is the process, not the print. The same Chrome printed the same HTML in about 110 ms when the page was already open in a long-running Puppeteer process. Grover starts Node.js and Chrome for every call, which is where the other 2.8 seconds go on our machine. Our Ferrum vs Grover comparison measures that start-up cost in more detail.
- Grover and raw Chrome made byte-identical sizes. Both wrote 118,514 bytes and 3 pages, so Grover adds no formatting of its own. What you see in Chrome's print preview is what you get.
- Prawn is fast and small, but it is a different job. The Prawn file has no HTML behind it. We wrote the header, the table and the footer in Ruby. It is smaller because it uses the PDF built-in Helvetica instead of embedding fonts.
Option 1: wicked_pdf
wicked_pdf is the gem many older Rails apps already have. A controller action looks like this:
# Not run for this page (see the test box above). class InvoicesController < ApplicationController def show @invoice = Current.account.invoices.find(params[:id]) respond_to do |format| format.html format.pdf do render pdf: "invoice-#{@invoice.number}", template: 'invoices/show', layout: 'pdf', page_size: 'A4' end end end end
The gem itself is still usable, but the engine is the problem. wkhtmltopdf is built on a WebKit from the early 2010s, its repository is archived, and the builds in Linux distributions often lack the patched Qt that features such as headers and footers need. Flexbox needs old -webkit-box syntax, CSS grid does not work, and modern JavaScript can fail without an error. Our wkhtmltopdf alternative page lists what broke in our tests and includes a drop-in WickedPdf.new-style shim, so we do not repeat that here.
Keep wicked_pdf if your templates already print correctly and nobody plans to change them. Do not choose it for a new app.
Option 2: Grover
Grover renders HTML with the Chrome that Puppeteer installs. Add it to the app:
bundle add grover npm install [email protected] # in the Rails root, next to package.json
The call we timed:
require 'grover' html = ApplicationController.render( template: 'invoices/show', layout: 'pdf', assigns: { invoice: invoice } ) pdf = Grover.new(html, format: 'A4', print_background: true).to_pdf
Grover passes options through to Puppeteer's page.pdf(), in snakecase: format, `printbackground,margin: { top: '18mm', ... },displayheaderfooter,headertemplate,footertemplate,prefercsspage_size. Everything in our [Puppeteerpage.pdf()guide](/blog/puppeteer-html-to-pdf) applies, including the two surprises we measured there: withoutformatthe paper is Letter, and@page { size: A4 }in your CSS overrideslandscape: true`.
Rails-specific points:
- Use a PDF layout with inline CSS.
ApplicationController.renderreturns HTML with/assets/...paths, and Chrome has no host to resolve them against. Either inline the stylesheet inapp/views/layouts/pdf.html.erbor pass Grover the base URL with itsdisplay_urloption. Inline CSS also keeps the PDF independent of your CDN. ApplicationController.renderhas no request. URL helpers needdefault_url_optionsset for the environment, andCurrentattributes are empty unless you set them in the job.- Grover has a Rack middleware that serves
/invoices/1.pdfby printing/invoices/1. It is convenient, and it renders inside the web request, which ties up a Puma thread for the length of the print. Prefer a job. - Deployment adds Node.js and Chrome to the image. On a Debian-based Rails 8 Dockerfile that means installing Node, running
npm ci, and adding the Chrome system libraries and fonts. Kamal deploys it like any other layer, but the image gets much larger; measure yours before and after.
Option 3: Prawn
Prawn draws a PDF in Ruby. There is no HTML, no CSS and no browser. This is the code we timed, trimmed to the parts that matter:
require 'prawn' require 'prawn/table' def invoice_pdf(invoice) Prawn::Document.new(page_size: 'A4', margin: [51, 40, 62, 40]) do |doc| doc.text 'Invoice', size: 22, color: '0F766E' doc.text invoice.number doc.move_down 12 rows = invoice.lines.map { |l| [l.position.to_s, l.description, l.quantity.to_s, l.amount_label] } doc.table([%w[# Item Qty Amount]] + rows, header: true, width: doc.bounds.width, cell_style: { size: 10, borders: [:bottom], border_color: 'E2E8F0' }) do row(0).background_color = 'F1F5F9' columns(2..3).align = :right end doc.start_new_page doc.text 'Terms', size: 16 doc.text 'Payment due in 30 days.' doc.number_pages 'Page <page> of <total>', at: [doc.bounds.right - 100, -20], width: 100, align: :right, size: 8 end.render end
header: true repeats the header row on each page, and number_pages stamps the footer after layout, like totalPages in Chrome. Prawn took 80 ms per invoice with no extra processes, which makes it the right tool for high volumes of fixed-format documents: receipts, labels, tickets, statements.
The cost is design work. Every layout change is a Ruby change, a designer cannot edit the template, and the PDF will never match the HTML version of the same invoice unless you maintain both. The built-in fonts cover Latin-1 only; for other scripts you register a TTF with font_families.update.
Option 4: an HTTP API
The fourth option keeps Grover's HTML templates and drops the browser from your servers. Our Ruby SDK sends the HTML and returns the PDF bytes. This follows the Ruby SDK reference and the PDF options; it was not run for this page:
# app/services/invoice_pdf.rb class InvoicePdf def self.render(html) client = RenderScreenshot.client(Rails.application.credentials.renderscreenshot_api_key) client.take( RenderScreenshot::TakeOptions .html(html) .format('pdf') .pdf_paper_size('a4') .pdf_print_background .pdf_margin('18mm') .pdf_footer('<div style="font-size:8px;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>') ) end end
Each successful PDF uses one credit, whatever its page count. The free plan has 150 credits a month, and Growth is $40 for 12,000. The trade-off is a network call per document and a dependency on our service; in return the Rails image has no Node.js or Chrome, and the 2.8 s process start goes away.
Saving the PDF with Active Storage
Whatever renders the bytes, the Rails side is the same: render the HTML, make the PDF in a job, attach it. Here is the job with a small switch so you can move between Grover and the API without touching callers. The structure is standard Rails 8 (Solid Queue, Active Storage); we did not run it in a full app for this page.
# app/models/invoice.rb class Invoice < ApplicationRecord has_one_attached :pdf end
# app/jobs/generate_invoice_pdf_job.rb class GenerateInvoicePdfJob < ApplicationJob queue_as :pdfs # Needs the renderscreenshot gem in the Gemfile, even while PDF_BACKEND is grover. retry_on RenderScreenshot::RateLimitError, wait: :polynomially_longer, attempts: 5 def perform(invoice) html = ApplicationController.render( template: 'invoices/show', layout: 'pdf', assigns: { invoice: invoice } ) bytes = case Rails.configuration.x.pdf_backend when :grover then Grover.new(html, format: 'A4', print_background: true).to_pdf when :api then InvoicePdf.render(html) else raise ArgumentError, "unknown PDF backend #{Rails.configuration.x.pdf_backend.inspect}" end invoice.pdf.attach( io: StringIO.new(bytes), filename: "invoice-#{invoice.number}.pdf", content_type: 'application/pdf' ) end end
# config/application.rb (inside the Application class) config.x.pdf_backend = ENV.fetch('PDF_BACKEND', 'grover').to_sym
And the controller only sends what is already stored:
def show invoice = Current.account.invoices.find(params[:id]) if invoice.pdf.attached? redirect_to rails_blob_path(invoice.pdf, disposition: 'attachment') else GenerateInvoicePdfJob.perform_later(invoice) head :accepted end end
Why a job: a Grover call took about 3 s on our machine and an API call is a network round trip. Either one inside a web request holds a Puma thread and fails the user's request if the renderer hiccups. A job retries on its own. Give PDF jobs their own queue (queue_as :pdfs) so a burst of invoices cannot delay password-reset emails.
Which one to pick
| Your situation | Pick | Reason |
|---|---|---|
| New app, designers own the templates in HTML/CSS | Grover or an API | Current Chrome; templates are views |
| Thousands of fixed-layout receipts or labels per hour | Prawn | 80 ms, no browser, small files |
| Existing wicked_pdf templates that print fine | Keep wicked_pdf for now | Plan a move; the engine gets no fixes |
| Small Docker image, no Node.js in production | Prawn or an API | Grover needs Node and Chrome |
| PDF must match the web page exactly | Grover or an API | Same engine as the browser |
| Strict data residency, no third parties | Grover or Prawn | Everything stays on your servers |
Troubleshooting
| Symptom | Library | Cause | Fix |
|---|---|---|---|
| PDF has no styles | Grover, wicked_pdf | Relative /assets/ paths in the HTML |
Inline CSS in the PDF layout, or Grover's display_url |
| Paper is Letter, not A4 | Grover | No format passed |
format: 'A4' or prefer_css_page_size: true |
landscape: true ignored |
Grover | @page { size: A4 } in the CSS |
Remove size or set size: A4 landscape |
| Flex or grid layout collapsed | wicked_pdf | Old WebKit engine | Move to Grover or an API |
| Headers and footers missing | wicked_pdf | Distro wkhtmltopdf without patched Qt |
Use the upstream build, or move off it |
Grover::DependencyError or Chrome not found |
Grover | Puppeteer not installed where Grover looks | Run npm install puppeteer in the app root on the server |
| Each PDF takes seconds | Grover | Process start per call | Run it in a job; or a long-lived browser (Ferrum) |
| Request timeouts in production | Any | PDF rendered in the web request | Move to Active Job |
| Missing characters (boxes) | Prawn | Built-in fonts are Latin-1 | Register a TTF font family |
| Missing characters in Docker | Grover | No fonts in the image | Install fonts-noto-core and fonts-noto-cjk |
What we ran and what we did not
Ran on 2026-10-18: the Grover and Prawn calls in the results table, on our invoice fixture, five times each in one Ruby process; the reference Puppeteer run in Node.
Not run: wickedpdf (no wkhtmltopdf binary on the test machine), the controller and job code inside a real Rails 8 app, Grover's middleware and `displayurl`, and the RenderScreenshot SDK call. We produced no rendered images for this post.
To try the API path on your own invoice view, create a free account, set PDF_BACKEND=api in development, and compare the attached file with the Grover one.
Related in this topic
- HTML to PDF API: Paper, Margins, Page Numbers Turn a URL or your HTML into a PDF with real Chrome: paper sizes, margins, header and footer templates with page numbers, one-page fit and print CSS.
- Best HTML to PDF APIs (2026): 7 Options Compared DocRaptor, PDFShift, Api2Pdf, PDFMonkey, Gotenberg, Browserless and RenderScreenshot compared: engines, prices at three volumes, code and an engine test.
- HTML to PDF in Node.js: Puppeteer, Playwright, PDFKit HTML to PDF in Node.js with Puppeteer, Playwright, PDFKit or an API, tested on an invoice and a chart report: times, file sizes, page numbers and pitfalls.
- HTML to PDF in Python: WeasyPrint vs pdfkit vs Playwright HTML to PDF in Python with WeasyPrint, pdfkit (wkhtmltopdf), Playwright or an API, tested on an invoice and a chart report: CSS support, timings, pitfalls.
- Laravel HTML to PDF: dompdf, Browsershot or laravel-pdf Generate PDFs from Blade views in Laravel: dompdf, Browsershot, spatie/laravel-pdf and an API, measured on one invoice, with queued jobs, Storage and tests.
- URL to PDF in Node.js and Python, Tested on Live Pages Convert a URL to PDF in Node.js and Python: print media, cookie banners that repeat on every page, lazy images that never load, with tested scripts.