LaunchKit
← All posts
· 16 min read · by The LaunchKit team · 0 views

Generating PDFs in Rails

Search "ruby on rails pdf generator" and you get a list of gems, which is not the question. The question is that Rails renders HTML and your accountant wants a file, and the two ways to close that gap have completely different failure modes: draw the document from primitives in Ruby, or render the ERB you already have and hand it to a browser to print. This page runs both against the same invoice and prints what comes out.

Everything below was run on Ruby 4.0.5, Rails 8.1.3.1, prawn 2.5.0, prawn-table 0.2.2, pdf-reader 2.16.0 and ferrum 0.18.0, against PostgreSQL on port 15432, with Google Chrome 153.0.8010.53. The machine is an Apple M2 Max, 12 cores, arm64-darwin25, and it was not idle: load average sat between 6 and 13 for the whole session. Treat the absolute milliseconds as an upper bound and the ratios as the finding.

What Rails gives you, which is less than people assume and more than nothing

There is no PDF renderer in Rails and there never has been. What there is, and what almost every tutorial tells you to add anyway, is the MIME type:

$ bin/rails runner 'puts Mime[:pdf].inspect'
#<Mime::Type:0x0000000125699bc0 @symbol=:pdf, @synonyms=[], @string="application/pdf", @hash=972024865665150308>

That is a fresh rails new with nothing in config/initializers/mime_types.rb. So format.pdf in a respond_to block and /invoices/1.pdf routing to it are free, and the Mime::Type.register "application/pdf", :pdf line you have seen in a hundred blog posts is already there, at actionpack-8.1.3.1/lib/action_dispatch/http/mime_types.rb:53:

Mime::Type.register "application/pdf", :pdf, [], %w(pdf)

The controller that produced every measurement here is fifteen lines:

class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.includes(:line_items).find(params[:id])

    respond_to do |format|
      format.html
      format.pdf do
        send_data PrawnInvoice.new(@invoice).render,
                  filename: "#{@invoice.number}.pdf",
                  type: "application/pdf",
                  disposition: "inline"
      end
    end
  end
end

disposition: "inline" opens it in the browser's viewer, "attachment" downloads it. The header Rails actually writes is worth reading once, because it emits the filename twice:

content-type: application/pdf
content-disposition: inline; filename="INV-2026-0041.pdf"; filename*=UTF-8''INV-2026-0041.pdf

The two pipelines, on one invoice

The fixture is a 60-line invoice: Invoice with number, customer, issued_on, total_cents, and 60 LineItem rows at 4900 cents each, total 294000. Both generators get the same record.

The Prawn side draws it. prawn-table handles the wrapping and header: true is what repeats the column headings on page two:

class PrawnInvoice
  def initialize(invoice) = @invoice = invoice

  def render
    Prawn::Document.new(page_size: "A4", margin: [51, 45]) do |pdf|
      pdf.font "Helvetica"
      pdf.text "Invoice #{@invoice.number}", size: 22, style: :bold
      pdf.text "#{@invoice.customer} - issued #{@invoice.issued_on}", size: 10, color: "666666"
      pdf.move_down 20
      rows = [ %w[Description Qty Unit Amount] ]
      @invoice.line_items.each do |li|
        rows << [ li.description, li.quantity.to_s,
                  format("$%.2f", li.unit_cents / 100.0),
                  format("$%.2f", li.total_cents / 100.0) ]
      end
      pdf.table(rows, header: true, width: pdf.bounds.width,
                cell_style: { borders: [ :bottom ], border_width: 0.5, padding: [ 5, 0 ], size: 9 }) do
        row(0).font_style = :bold
        row(0).border_width = 1.5
        columns(1..3).align = :right
      end
      pdf.move_down 12
      pdf.text format("Total  $%.2f", @invoice.total_cents / 100.0), align: :right, style: :bold
    end.render
  end
end

The Chrome side renders app/views/invoices/show.html.erb, writes it to a temp file and shells out:

class ChromePdf
  BINARY = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".freeze

  def self.render(html)
    Dir.mktmpdir do |dir|
      input  = File.join(dir, "in.html")
      output = File.join(dir, "out.pdf")
      File.write(input, html)
      ok = system(
        BINARY, "--headless", "--disable-gpu", "--no-sandbox",
        "--no-pdf-header-footer", "--run-all-compositor-stages-before-draw",
        "--print-to-pdf=#{output}", "file://#{input}",
        out: File::NULL, err: File::NULL
      )
      raise "chrome exited #{$?.exitstatus}" unless ok
      File.binread(output)
    end
  end
end

A file URL rather than a loopback URL because a subprocess hitting your own Puma while Puma is holding a thread waiting for that subprocess is how you deadlock a one-thread server. If the template needs images or a stylesheet, they have to be inlined or absolute, which is the first tax this approach charges.

Timings, best of 15 runs each, from bin/rails runner:

load average now: 11.35 9.85 8.20
invoice: INV-2026-0041, 60 line items
best of 15, prawn                        102.8 ms
best of 15, ferrum on a live browser     201.4 ms
best of 15, chrome --print-to-pdf        716.8 ms
            ferrum first call (boot)     629.2 ms
load average after: 13.08 10.33 8.41

The third line is not a measurement of rendering. It is a measurement of Chrome starting up, which the fourth line isolates at 629.2 ms. Proof: a 3-line invoice through the same subprocess took 1116.8 ms over 10 runs while the 60-line one took 792.4 ms over 20 in an earlier pass. The document does not move the number. The process does.

ferrum 0.18.0 is the fix for that, and it is thirty lines. Keep one browser alive, open a page per document, close the page and not the browser:

class FerrumPdf
  def self.browser
    @browser ||= Ferrum::Browser.new(headless: true, timeout: 20)
  end

  def self.render(html)
    Dir.mktmpdir do |dir|
      path = File.join(dir, "in.html")
      File.write(path, html)
      page = browser.create_page
      begin
        page.go_to("file://#{path}")
        out = File.join(dir, "out.pdf")
        page.pdf(path: out, format: :A4, printBackground: true, preferCSSPageSize: true)
        File.binread(out)
      ensure
        page.close
      end
    end
  end
end

That @browser ||= is a class-level instance variable shared by every Puma thread in the process, which is the cost: one Chrome per worker, leaking pages if an exception skips the ensure, and a dead browser after a crash that nothing restarts. Ferrum::Browser is not thread-safe as a page factory in any documented way. If you go this route the browser belongs in a job process with one thread, not in the web workers.

Output, from the same run:

html        12879 bytes
prawn.pdf   40114 bytes
chrome.pdf  111038 bytes

Nearly three times the bytes for the same invoice, and strings says why:

$ for f in out/prawn.pdf out/chrome.pdf; do printf "%-16s FontFile occurrences: " "$f"; strings "$f" | grep -c "FontFile"; done
out/prawn.pdf    FontFile occurrences: 0
out/chrome.pdf   FontFile occurrences: 4

Prawn with pdf.font "Helvetica" embeds nothing, because Helvetica is one of the PDF base 14 and every reader is required to have it. Chrome embeds a subset of every face it drew with. The names carry the subset prefix, and the set changes page to page. Printed by pdf-reader from page.fonts.values.map { _1[:BaseFont] }:

== out/prawn.pdf  pdf version 1.3, 2 pages
   page 1 fonts: Helvetica, Helvetica-Bold
   page 2 fonts: Helvetica, Helvetica-Bold
== out/chrome.pdf  pdf version 1.4, 3 pages
   page 1 fonts: AAAAAA+HelveticaNeue-Bold, BAAAAA+HelveticaNeue, CAAAAA+HelveticaNeue-Bold
   page 2 fonts: BAAAAA+HelveticaNeue, CAAAAA+HelveticaNeue-Bold
   page 3 fonts: BAAAAA+HelveticaNeue, CAAAAA+HelveticaNeue-Bold, DAAAAA+HelveticaNeue-Bold

Also note the page counts: two against three, from the same data, because 11pt with 2mm cell padding in CSS is not 9pt with 5pt padding in Prawn. Nothing is wrong with either. It is just that "the same invoice" stops meaning one thing the moment two engines lay it out.

The total printed on all three pages

The ERB had the total in a <tfoot>, which is where a total belongs in HTML:

<tfoot>
  <tr><td colspan="3" class="num">Total</td><td class="num"><%= number_to_currency(@invoice.total_cents / 100.0) %></td></tr>
</tfoot>

On screen that renders once, at the bottom of the table. Printed, pdf-reader found this:

page 1: 1 line(s) starting with Total -> ["Total          $2,940.00"]
page 2: 1 line(s) starting with Total -> ["Total          $2,940.00"]
page 3: 1 line(s) starting with Total -> ["Total          $2,940.00"]

$2,940.00 three times, once on every page. Nothing on page 2 says it is the same $2,940.00 as the one on page 1, and nothing on page 1 says the table continues. tfoot defaults to display: table-footer-group, and a table footer group in paged media is defined as a footer for each page fragment the table occupies, exactly like thead is a repeating header. The browser is correct and the template is wrong. It only shows up past one page, which is why it reaches production: a seed invoice with four line items never leaves page one, and neither does the request spec that asserts the PDF is a PDF.

The fix is that a grand total is not a table footer, it is a block after the table:

    </table>
    <p class="total">Total <%= number_to_currency(@invoice.total_cents / 100.0) %></p>

with .total { text-align: right; font-weight: 700; margin-top: 4mm; } replacing the tfoot td rule. Re-run, and the header still repeats while the total does not:

page 1: totals=[]  header repeated=true
page 2: totals=[]  header repeated=true
page 3: totals=["Total $2,940.00"]  header repeated=true

Prawn never had this problem, because pdf.text after pdf.table is a cursor position and there is no concept of a per-page footer unless you ask for one with repeat(:all). That is the actual trade: the HTML pipeline inherits a layout engine that knows what a page is, along with every rule that engine has about what repeats.

If you take one thing from this page, make your PDF fixture long enough to break over a page. The spec below asserts three pages for exactly this reason.

Chrome puts your filesystem on the invoice

Drop --no-pdf-header-footer and Chrome prints its default header and footer, which is the page URL and the page number. The URL of a temp file:

default chrome, page 1 last line: "file:///var/folders/ml/lgmy7_c13d9g3p5jkkpfspmw0000gn/T/d20260927-29635-jjx6pg/in.html              1/3"
default chrome, page 2 last line: "file:///var/folders/ml/lgmy7_c13d9g3p5jkkpfspmw0000gn/T/d20260927-29635-jjx6pg/in.html              2/3"
default chrome, page 3 last line: "file:///var/folders/ml/lgmy7_c13d9g3p5jkkpfspmw0000gn/T/d20260927-29635-jjx6pg/in.html              3/3"

That is extracted text from the PDF, not a screenshot, so it is in the document a customer can select and copy, on every page. --no-pdf-header-footer is not optional.

Determinism, and the ETag nobody expects

Render the same invoice twice through each generator and hash the bytes:

prawn  run 0: 70f908f53fd250634ccc0e87f6224c7d
prawn  run 1: 70f908f53fd250634ccc0e87f6224c7d
chrome run 0: d5b636e7c012392a4a064adb5fde912f
chrome run 1: 6f424c7ac8800783ca66bc8637a902f7

Prawn is byte-stable, and stays byte-stable over 20 renders. Chrome is not, because it writes /CreationDate (D:20260927152715+00'00') into the trailer and that moves every second. Prawn writes no CreationDate at all.

This is not trivia, because Rails weak-ETags a send_data response body whether you asked it to or not. From the request spec:

it "weak-ETags the Prawn body, and the ETag is stable across requests" do
  get invoice_path(invoice, format: :pdf)
  first = response.headers["ETag"]
  expect(first).to match(/\AW\/"[0-9a-f]{32}"\z/)
  get invoice_path(invoice, format: :pdf)
  expect(response.headers["ETag"]).to eq(first)

  get invoice_path(invoice, format: :pdf), headers: { "HTTP_IF_NONE_MATCH" => first }
  expect(response).to have_http_status(:not_modified)
end

That passes, and the Prawn endpoint gets conditional GET for free: a repeat visit answers 304.

The Chrome endpoint is where I got it wrong the first time. The obvious assertion is that its ETag differs on every request, and I wrote that spec and watched it go green:

it "gives the Chrome body a different ETag on every request" do
  get chrome_invoice_path(invoice)
  first = response.headers["ETag"]
  get chrome_invoice_path(invoice)
  expect(response.headers["ETag"]).not_to eq(first)
end

Green, and wrong. Running the file five more times failed it once:

  1) GET /invoices/:id.pdf gives the Chrome body a different ETag on every request
     Failure/Error: expect(response.headers["ETag"]).not_to eq(first)

       expected: value != "W/\"483d6238c41c8ba774a1e6b26c9aa009\""
            got: "W/\"483d6238c41c8ba774a1e6b26c9aa009\""

/CreationDate (D:20260927200808+00'00') has one-second resolution. A subprocess render takes about 700 ms, so two consecutive renders usually straddle a second boundary and usually differ - usually. Twenty renders in a row:

distinct: 15 of 20
distinct CreationDate values: 16 of 20 -> ["D:20260927200825+00'00'", "D:20260927200826+00'00'", ...]

Five of those twenty were byte-identical to the render before them. So the Chrome endpoint's ETag is not "new every time", it is new about three quarters of the time, and that is worse than either extreme. A stable ETag caches correctly. An always-new ETag never caches, which is slow but honest. An ETag that is new 15 times out of 20 means a client holding If-None-Match gets a 304 on a sub-second retry and a full regeneration otherwise, for the same unchanged invoice, and no amount of staring at one request will show you which. The fix if you are on Chrome is fresh_when(@invoice) before you generate anything, so the ETag comes from updated_at rather than from the body, and the expensive part never runs on a 304. That means you also have to remember to touch the invoice when a line item changes, which belongs_to :invoice, touch: true does and the default belongs_to does not.

Prawn's built-in fonts stop at Windows-1252

Four customer names through Prawn::Document.new { |p| p.text s }:

OK    "Norrsken Labs AB"
OK    "Société Générale"
OK    "€49.00"
RAISE "株式会社カヤック" -> Prawn::Errors::IncompatibleStringEncoding: Your document includes text that's not compatible with the Windows-1252 character set.
If you need full UTF-8 support, use external fonts instead of PDF's built-in fonts.

Accents pass. The euro sign passes, because Windows-1252 has one at byte 128 where Latin-1 does not: "€".encode("Windows-1252").bytes is [128] and "€".encode("ISO-8859-1") raises Encoding::UndefinedConversionError: U+20AC from UTF-8 to ISO-8859-1. Japanese raises, in production, on a customer name, from a code path your tests do not cover because your fixtures are English. Prawn also prints a warning to stderr the first time you use a built-in font at all:

PDF's built-in fonts have very limited support for internationalized text.
If you need full UTF-8 support, consider using an external font instead.

To disable this warning, add the following line to your code:
Prawn::Fonts::AFM.hide_m17n_warning = true

The fix is pdf.font_families.update with a TTF, and then you are shipping and licensing a font file, and you have inherited the problem of which font covers CJK, which is not a small problem. Chrome rendered the same string without being asked, because the operating system had a face for it, which is a different way of saying the same problem exists and somebody else solved it in 2009.

What Chrome costs in a container

Measured, not estimated, on ruby:4.0.5-slim:

FROM ruby:4.0.5-slim
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y chromium fonts-dejavu-core && \
    rm -rf /var/lib/apt/lists/*
$ docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep pdfsize
pdfsize:chromium 908MB
pdfsize:base 199MB

199MB to 908MB, a +709MB tax on every build, push and cold start, for a binary that renders an invoice. It does work, which is the other half of the claim:

$ docker run --rm -v "$PWD:/w" -w /w pdfsize:chromium sh -c 'chromium --version; chromium --headless --disable-gpu --no-sandbox --no-pdf-header-footer --print-to-pdf=/w/docker.pdf file:///w/in.html; ls -l /w/docker.pdf'
Chromium 154.0.8037.57 built on Debian GNU/Linux 13 (trixie)
-rw-r--r-- 1 root root 13911 Sep 27 15:33 /w/docker.pdf

--no-sandbox is in there because Chrome's sandbox needs user namespaces the container usually is not given. Running a browser as root with the sandbox off, on HTML your application assembled, is a thing to have decided deliberately rather than copied.

wicked_pdf, and why it is not in the comparison

Because the thing under it stopped. wkhtmltopdf is the binary wicked_pdf and pdfkit shell out to, and its repository reports, from the GitHub API on 2026-09-27:

"archived": true,
"pushed_at": "2022-11-22T10:32:12Z"

wicked_pdf itself is alive, "archived": false with a last push of 2025-07-24 and 313 open issues at the same reading, but a maintained wrapper around an archived renderer is still a wrapper around a QtWebKit build that stopped receiving commits in 2022, security ones included. Nothing here ran wkhtmltopdf, so this page makes no claim about what it renders, only about who is maintaining it. If you have a working wicked_pdf setup, it will keep working; if you are choosing today, you are choosing an engine with no upstream.

The verdict

Neither of these belongs in a web request. The floor is 102.8 ms of pure Ruby, and pure Ruby holds the GVL. Eight invoices, sequentially and then across eight threads in one process:

prawn  8 docs sequential   0.82s | 8 threads   0.73s | speedup 1.12x
chrome 8 docs sequential   6.34s | 8 threads   3.54s | speedup 1.79x

Twelve cores and Prawn got 1.12x, because there is nothing in Prawn::Document#render that releases the interpreter lock. A Prawn call does not just take 100 ms, it takes 100 ms during which the other threads in that Puma process get almost nothing. Chrome got 1.79x on the same already-loaded box, because system() releases the GVL and the work happens in a different process entirely. That is the one argument for the browser that has nothing to do with CSS. Generate in a job, store in Active Storage, and have the controller redirect to the blob. The endpoints in this page exist to be measured, not to be copied.

Given that, the choice is: Prawn when the document is a form you control and the text is Latin, headless Chrome through a long-lived browser when a designer will touch it. The deciding question is not performance, it is who edits the layout next year. A Prawn invoice is a Ruby file only a Ruby developer can change; an ERB invoice is a template anyone who writes CSS can change, and that is worth 100 ms and 700MB more often than the benchmark suggests.

What would change it: a Prawn release that embeds a UTF-8 default font, or a maintained Ruby binding to a layout engine that is not a full browser. Typst and WeasyPrint both look like that engine from the outside. Neither is installed here, so this page has nothing to say about them and does not pretend to.

What this page does not cover

Not covered: any of the pure-Ruby alternatives to Prawn, in particular HexaPDF, which was not installed here and therefore does not appear in a single number above. Not covered: Typst, WeasyPrint, Paged.js, or any paid PDF API, for the same reason - nothing here ran them, so nothing here has an opinion. Not covered: filling or merging existing PDFs, which is combine_pdf and hexapdf territory and shares none of the layout problem above. Not covered: PDF/A, digital signatures, or anything an invoice has to satisfy to be legally archivable in the EU, which is a real constraint for anyone billing in France or Italy and is entirely outside both generators. Not covered in any depth: page numbering, which Prawn does with number_pages and Chrome does with CSS margin boxes. That one got checked, because it was expected to fail and it does not:

$ # @page { @bottom-right { content: "Page " counter(page) " of " counter(pages); } }
page 1: ["one", "Page 1 of 3"]
page 2: ["two", "Page 2 of 3"]
page 3: ["three", "Page 3 of 3"]

Chrome 153 resolves counter(pages) correctly, which means it laid the whole document out before filling the box in. Nothing else about margin boxes was tested.

The measurements are one laptop under load, one Chrome build, one 60-line invoice. The 20 examples that assert the structural claims - page counts, repeated headers, the tfoot behaviour, the encoding failure, the ETags - are in a scratch app and pass in 14.24 seconds against PostgreSQL 17.7 on port 15432. Three of those twenty exist because the first version of the ETag example passed and was still wrong.

#rails #views

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.