Performance
Benchmarks against Prawn and sghtmltopdf, the metrics gate CI holds, what a long document holds in memory, a StackProf profile, and the events a render emits.
Benchmarks#
From the README — rake bench.
bundle exec rake bench renders three documents with Stationery, with Prawn 2.5 + prawn-table, and with sghtmltopdf 0.5.1, an HTML-to-PDF engine written in Rust, given HTML/CSS twins of the same documents. Every engine embeds the same Open Sans TTF files (benchmark/, not part of CI). Apple M2 Max, Ruby 3.4.2 +YJIT, 29 September 2026 (0.12.0), warm caches, the best of three runs:
| Document | Engine | Best render | Renders/s | Objects allocated | PDF bytes | Pages |
|---|---|---|---|---|---|---|
Invoice (examples/invoice.rb) | Stationery | 11.3 ms | 79.7 | 27,531 | 26,601 | 1 |
| Prawn | 17.6 ms | 49.0 | 88,558 | 33,034 | 1 | |
| sghtmltopdf | 3.2 ms | 293.8 | 53 | 36,693 | 1 | |
| Table, 1,500 rows × 5 columns, repeating header | Stationery | 205 ms | 4.46 | 1,276,408 | 133,756 | 46 |
| Prawn | 1,299 ms | 0.74 | 6,901,819 | 2,689,487 | 40 | |
| sghtmltopdf | 94 ms | 10.05 | 53 | 369,338 | 45 | |
| Cover photo and six illustrated sections | Stationery | 6.8 ms | 136.3 | 25,977 | 41,533 | 3 |
| Prawn | 26.9 ms | 31.0 | 102,961 | 55,391 | 2 | |
| sghtmltopdf | 6.6 ms | 140.2 | 53 | 43,031 | 2 |
sghtmltopdf is the fastest on text and tables: 3.5x Stationery on the invoice and 2.2x on the table, and level with it on the photo document (6.6 against 6.8 ms). That is native code against Ruby, and its 53 objects are the binding's: the engine allocates outside the Ruby heap. Stationery is 1.6x, 6.3x and 4.0x faster than Prawn and writes the smallest files of the three (28%, 64% and 3% smaller than sghtmltopdf's). Stationery compresses content streams; Prawn does not by default, hence its larger files. Against 0.11.0, the same three renders allocate 30% to 51% fewer objects and take 14%, 48% and 24% less time. Page counts differ where the engines' line heights and table padding do. "Best render" is the fastest single render, the figure other work on the machine disturbs least; renders per second is the average over five seconds.
What Stationery optimises for is not the last millisecond: it is pure Ruby with no native extension to compile or precompile, its output is the same bytes on every platform, and the PDF features (tagged output, PDF/A, forms, signatures) live in the same process as your models. See the comparison for what each engine does.
PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb prints the 20 hottest frames of the table render under StackProf (wall mode; MODE=cpu or MODE=object for the others). SGHTMLTOPDF=0 bundle exec rake bench leaves the third engine out, as does a machine without its gem.
A lossless WebP is decoded in Ruby when it is first loaded, which a JPEG or an opaque PNG (both passed through) never is: a 1000 × 1000 px image takes 0.2 to 0.4 s on the machine above, by what is in it, and the image cache keeps it for the renders that follow. A JPEG is decoded only for to_png or a monochrome render, once per Ractor and scale: a 1000 × 1000 px photo in 0.25 s, a 12-megapixel phone photo in 2.8 s at full size and 0.7 s at the quarter size a label or a preview needs (progressive: 3.4 s and 1.4 s; about four times as long without YJIT).
Time depends on the machine, so CI holds what does not: bundle exec rake metrics renders fourteen fixed documents and compares the objects each render allocates, its page count and its bytes with benchmark/baseline.json (allocations may grow 3%, bytes 1%, pages not at all). A change that moves them on purpose records a new baseline with bundle exec rake metrics:update and says why in the commit.
| Document | What it holds |
|---|---|
invoice, flyer, form | The examples of those names: a table with a footer, images and drawings, form fields |
table | 1,500 rows × 5 columns with a repeating header, 46 pages |
text, text_hyphenated, text_streamed | Ten pages of headings and paragraphs: as they are, justified and hyphenated, and written to a block |
article | Floats: examples/article.rb |
newsletter | columns: examples/newsletter.rb |
webp | A lossless WebP of 320 × 240 px, decoded in the render that is measured |
html | html with a stylesheet, inline styles, a floated image, a table, lists and two columns, five pages |
pdf_ua | A tagged report under conformance :pdf_ua1, six pages |
text_incremental | The text document with a footer, rendered with incremental to a block |
text_shaped | The text document through a shaper written in Ruby, which answers the font's own glyphs |
bundle exec rake memory reports what long documents hold while they render (PAGES=5000 for more than its 1,000 pages): the peak resident set size of a render in a fresh process, and what is alive when building, pagination and writing end. It moves with the machine and gates nothing; the figures are under Large documents.
Running them#
Time locally, allocations and bytes in CI.
bundle exec rake bench # Stationery vs Prawn vs sghtmltopdf, renders/s
bundle exec rake metrics # allocations, pages and bytes against the baseline
PAGES=1000 bundle exec rake memory # peak memory of long documents, and what stays alive
PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb # 20 hottest frames under StackProfThe documents live in benchmark/; every engine embeds the same Open Sans TTF files, and sghtmltopdf renders HTML/CSS twins of them at dpi: 72 so a CSS pixel is a point. rake bench needs the Gemfile's :benchmark group (Prawn, prawn-table, benchmark-ips, StackProf and the precompiled sghtmltopdf gem); without sghtmltopdf it prints two engines and says so, and SGHTMLTOPDF=0 does the same on purpose. None of them is a dependency of the gem.
Time depends on the machine, so rake bench is not part of CI. rake metrics is: it renders fourteen fixed documents (the table above) and fails when the objects a render allocates grow more than 3%, its bytes more than 1%, or its page count changes, against benchmark/baseline.json. A change that moves them on purpose records a new baseline with rake metrics:update. The feature-by-feature picture is on the Comparison page.
Large documents#
What a render holds, and what incremental saves.
A render holds what it still has to paint: the nodes of a page are let go once it is painted, a table measures its rows as pages reach them, the fonts forget the lines they shaped after 64 KB of text, and a page nothing paints on again keeps its deflated content stream instead of its operators. Every form of to_pdf writes the same bytes as before.
Headers, footers, page templates and a table of contents are painted once every page is known, so a document with them holds the operators of every page until then. to_pdf(incremental: true) (or incremental at class level) writes each page's content as soon as the page is painted instead, and what is painted later as content streams of its own:
| Document | Pages | 0.10 | Now | incremental: true | |
|---|---|---|---|---|---|
| Headings and paragraphs | 1,000 | peak | 391 MB | 111 MB | 108 MB |
| alive | 216 MB | 17 MB | 14 MB | ||
| 5,001 | peak | 1,556 MB | 350 MB | 415 MB | |
| alive | 1,062 MB | 41 MB | 25 MB | ||
| With a footer and a table of contents | 1,039 | peak | 397 MB | 145 MB | 106 MB |
| alive | 222 MB | 44 MB | 11 MB | ||
| 5,189 | peak | 1,643 MB | 614 MB | 446 MB | |
| alive | 1,080 MB | 180 MB | 20 MB | ||
| One table of 33,000 rows | 1,000 | peak | 901 MB | 174 MB | 174 MB |
| alive | 494 MB | 26 MB | 23 MB | ||
| One table of 165,000 rows | 5,000 | peak | 3,792 MB | 584 MB | 586 MB |
| alive | 2,437 MB | 40 MB | 25 MB |
"Peak" is the resident set size of one render in a fresh process, "alive" the megabytes that survive a garbage collection when pagination ends (rake memory, Apple M2 Max, Ruby 3.4.2 +YJIT; the text documents measured on 0.11.0, the tables on 0.12.0). The peak moves with the machine, up to a fifth between two runs here, so nothing gates on it. A document without headers, footers, templates or contents gains nothing from incremental:, and what is left in every case is the document as it was built: every node but a table cell's exists before the first page is painted. A table with a column without a width resolves its column widths from every cell and keeps the widths, not the cell's text: a cell's node is built when a page reaches its row and let go with the page. In a tagged render a row's TR, TH and TD are built when a page paints it, and the structure tree holds them until the file is written, so the table of 165,000 rows tagged peaks at 2.2 GB. When incremental: takes the usual path instead is under Rendering.
A table whose every column has a width measures no cell for its columns, and given its rows as an Enumerator, lazy or not, it reads them as pages reach them (Elements): nothing of the table exists before its first page. A price list with a header row and zebra stripes, incremental: true:
| Rows | Pages | Rows given as | Peak | Alive when built | Alive when paginated |
|---|---|---|---|---|---|
| 100,000 | 3,031 | an Enumerator | 81 MB | 9 MB | 18 MB |
| an Array | 485 MB | 155 MB | 20 MB | ||
| 1,000,000 | 30,303 | an Enumerator | 130 MB | 9 MB | 47 MB |
| an Array | 2,758 MB | 1,474 MB | 64 MB |
What still grows with a streamed table is what the file needs of every page until it is written. Tagged, the structure tree holds the TR, TH and TD of every painted row until then, streamed or not: the 100,000 rows tagged peak at 989 MB streamed, 1,230 MB as an Array.
Instrumentation#
From the README — events for AppSignal and friends.
Every render reports its phases as events named <phase>.stationery, so an APM that subscribes to ActiveSupport::Notifications (AppSignal, Skylight, Rails' own log subscribers) shows where a slow PDF spends its time with no setup: AppSignal groups them under "stationery" in the event tree. Outside Rails the events go to a null instrumenter that costs one method call.
| Event | Around | Payload |
|---|---|---|
render.stationery | the whole to_pdf | document:, pages:, bytes:, warnings: (count) |
build.stationery | building the component tree | document: |
paginate.stationery | layout, page breaks and page templates | document:, pages: |
write.stationery | serialising, subsetting and deflating | document:, bytes: |
image.stationery | decoding one image (cache misses only) | format:, width:, height:, bytes: |
font.stationery | parsing a font file (action: :parse, cache misses only) or subsetting one for a document (action: :subset, glyphs:) | path: or font:, action: |
parse.stationery | parsing an html or markdown source | format:, bytes: |
Payload values known only afterwards (pages:, bytes:) are filled in before the event finishes, so subscribers always see them. Log every phase slower than 100 ms:
ActiveSupport::Notifications.subscribe(/\.stationery\z/) do |name, start, finish, _id, payload|
ms = (finish - start) * 1000
Rails.logger.info("#{name} #{ms.round}ms #{payload.inspect}") if ms > 100
endAny object answering instrument(name, payload) { |payload| } can take the events instead (Stationery.instrumenter = MyInstrumenter.new), and Stationery.instrument("custom.stationery", key: value) { … } adds your own spans inside a document. When you hit a slow render, a trace with these events attached is the most useful thing to put in an issue.
Threads, fibers and Ractors#
From the README — renders at once, and inside a Ractor.
A render keeps its state to itself: a document, its fonts' glyph usage and its pages belong to the render. What renders share is cached once per Ractor, behind the cache's own lock: parsed fonts (16 files), decoded images (16), colours parsed from Strings, hyphenation patterns. So any number of threads, or fibers of one Async reactor, may render at once, and a render inside a non-main Ractor (Ruby 4.0) fills caches of its own. A parsed font never changes once it is read, so it can be made shareable and handed to a Ractor as it is.
A Ractor can read only what is shareable, so boot ends with Stationery.finalize!, which freezes what renders read. The Railtie runs it once the app has initialized; a Rails-less app calls it once its setup is done:
class InvoicePdf < Stationery::Document
page size: :a4
footer { |page| text "#{page.number} / #{page.count}" }
def initialize(lines) = @lines = lines
def view_template = @lines.each { |line| text line }
end
Stationery.finalize! # outside Rails: font_paths and every document class loaded so far
pdfs = Array.new(4) { |i| Ractor.new(["Invoice #{i}"]) { |lines| InvoicePdf.new(lines).to_pdf } }.map(&:value)Stationery.finalize!freezesStationery.font_paths, keeps the instrumenter in use and finalizes every document class loaded by then, with their subclasses (Document.finalize!). In a Rails app withconfig.eager_load(production, and the CI test runs of a Rails 7.1+ app) that is every class underapp/. A class loaded later (autoloaded in development) or defined after boot is not finalized: it renders on the main Ractor as before, andInvoicePdf.finalize!finalizes it.- A finalized class's settings cannot change (a
FrozenError); a subclass has its own. They are made Ractor-shareable as far as what they hold lets them be, and nothing raises: a block of a page template, header or footer that uses an unshareable local from around it, ashaperor asignkey that is not shareable, is kept as it is, so the class renders on the main Ractor as before and not inside another one.Ractor.shareable?(InvoicePdf.config)says whether its settings are shareable; a render inside a Ractor also needs a shareable instrumenter (below), and the class's own methods (view_template, the methods its blocks call) may reach only what a Ractor can. A String the app gave is copied, not frozen in place, and an object the gem did not build is never frozen. - An instrumenter for renders inside a Ractor must be shareable: a module (
ActiveSupport::Notifications) or a frozen object. Another one is kept, for the main Ractor. - What a Ractor is handed (
Ractor.new(lines)) is copied unless it is shareable.
The gem is held to this by conformance-kit: bundle exec conformance-kit check (on Ruby 4.0) renders a document of text, shapes, a JPEG, a table, a QR code and a footer inside a Ractor and compares its bytes with the main Ractor's, and audits every constant and class instance variable of the gem for state a Ractor cannot read.
Why the trade-off#
Why not Prawn, Chrome or Typst?
- Prawn is an imperative cursor API: every document does its own layout arithmetic. Stationery is a layout engine with a component DSL.
- sghtmltopdf renders HTML and CSS in Rust, fast and with a real CSS layout engine, but writes no outline, forms, tagged PDF, PDF/A, encryption or signatures, and is a native extension.
- Headless Chrome (Grover, ferrum_pdf) renders HTML beautifully but runs a browser per worker; stray processes and memory are the price.
- Typst is excellent but a native extension and a second template language.