· Tutorials · By Simon Chiu

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 wkhtmltopdf binary.
  • 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.10 with puppeteer 24.43.1 on Node.js 22.18.0. prawn 2.5.0 and prawn-table 0.2.2. wicked_pdf was not run: wkhtmltopdf is 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 from pdfinfo.

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:

  1. 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.
  2. 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.
  3. 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.render returns HTML with /assets/... paths, and Chrome has no host to resolve them against. Either inline the stylesheet in app/views/layouts/pdf.html.erb or pass Grover the base URL with its display_url option. Inline CSS also keeps the PDF independent of your CDN.
  • ApplicationController.render has no request. URL helpers need default_url_options set for the environment, and Current attributes are empty unless you set them in the job.
  • Grover has a Rack middleware that serves /invoices/1.pdf by 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