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.
Comments
No comments yet. Be the first.