Ferrum and Grover are the two Ruby gems most Rails teams reach for when they need a screenshot or a PDF from Chrome. They do not compete on output: given the same Chrome, they made byte-identical PNGs in our test. They compete on how they reach Chrome, and that decides speed, deployment and how much code you write.
- Ferrum speaks the Chrome DevTools Protocol from pure Ruby. No Node, no Selenium, no ChromeDriver. You manage the browser.
- Grover shells out to a Node script that uses Puppeteer. You get Puppeteer's options and a Rack middleware, and you pay for a Node and Chrome start on each call.
Choose Grover for occasional PDFs of your own Rails pages. Choose Ferrum for volume, or when you cannot install Node.
Disclosure: I build RenderScreenshot, a hosted screenshot API with a Ruby SDK. Ferrum and Grover are open-source gems we do not maintain. We ran every number on this page on 2026-10-15.
Our comparison hub has more head-to-heads. For a full tutorial on the Ruby options, see website screenshots in Ruby.
Verdict
| Situation | Pick | Why |
|---|---|---|
| "Download as PDF" on a few Rails pages | Grover | Grover::Middleware turns /invoices/42.pdf into a PDF with no controller code |
| Hundreds of captures per hour in a background job | Ferrum | One warm browser: about 0.2 s per capture vs about 1.6 s per Grover call |
| Docker image without Node | Ferrum | Ruby gem plus a Chromium package |
| Team already knows Puppeteer options | Grover | Option names follow Puppeteer (wait_until, full_page, print_background) |
| You also drive forms, clicks and logins | Ferrum | Full page API: at_css, click, type, network interception |
| You want the simplest code for one PDF | Grover | One line: Grover.new(url, format: 'A4').to_pdf |
The measurement
Method: 2026-10-15, macOS on an Apple M1 Pro, Ruby 4.0.7, Ferrum 0.18.0, Grover 1.2.10 with Puppeteer 24.43.1. Both gems used the same binary, Chrome for Testing 148.0.7778.97, so the comparison measures the gems and not two browsers. Three runs per page and mode. We report the median, plus min and max.
We measured three ways of working:
- Ferrum cold:
Ferrum::Browser.new, capture,quit. This is a one-off script. - Ferrum warm: one browser for the whole run,
create_pageandcloseper capture. This is a long-running worker. One warm-up capture was not counted. - Grover:
Grover.new(...).to_pngor.to_pdf. Every call starts Node, Puppeteer and Chrome.
| Page and output | Ferrum cold | Ferrum warm | Grover | Output size |
|---|---|---|---|---|
| example.com, 1280x800 PNG | 766 ms (682–977) | 191 ms (184–200) | 1,707 ms (1,697–1,730) | 81 KB both |
| 1200x630 OG card PNG | 644 ms (644–657) | 226 ms (221–237) | 1,759 ms (1,744–1,779) | 227,633 bytes both, identical |
| 8,657 px full-page PNG, lazy images | 669 ms (638–682) | 245 ms (211–774) | 1,337 ms (1,320–1,388) | 87,686 bytes both, identical |
| 3-page invoice, A4 PDF | 628 ms (609–656) | 209 ms (183–237) | 1,767 ms (1,756–1,795) | 121 KB both |
| JavaScript page, A4 PDF | 607 ms (587–608) | 193 ms (191–195) | 1,605 ms (1,604–1,625) | 18 KB both |
Three findings matter more than the raw times:
- Grover's floor is about 1.3 seconds. That is the cost of a Node process plus a Chrome launch. It does not shrink with smaller pages.
- A warm Ferrum browser is about 3 to 4 times faster than a cold one, and 5 to 9 times faster than Grover. If you only take one screenshot per process, cold Ferrum is still 2 to 3 times faster than Grover.
- Network idle did not mean "page ready" in either gem. Our JavaScript page changes its text 800 ms after load. Ferrum's
network.wait_for_idle(duration: 0.5)returned before that change. Grover'swait_until: 'networkidle0'happened to catch it in this run. Neither is a guarantee. Wait for the element or the text you need, as shown below.
Both PDFs printed the @page counter "Page 1 of 3", because both are Chrome.
Ferrum: the code
# Ferrum 0.18: one long-lived Chrome, a new page per capture. require 'ferrum' browser = Ferrum::Browser.new(timeout: 30) begin page = browser.create_page page.set_viewport(width: 1280, height: 800) # window_size alone gave us a 1280x773 viewport page.go_to('https://example.com') page.network.wait_for_idle(duration: 0.5) page.screenshot(path: 'example-ferrum.png', full: true) page.pdf(path: 'example-ferrum.pdf', format: :A4, print_background: true) page.close ensure browser.quit end puts 'saved example-ferrum.png and example-ferrum.pdf'
The comment in that snippet is a real trap. Our first version used Ferrum::Browser.new(window_size: [1280, 800]) and the PNG came out 1280x773, because the window size includes space the page cannot use. set_viewport sets the page area exactly.
Ferrum has no "wait for this text" helper, so add a small poll:
# Ferrum has no "wait for this text" call, so poll the page until your own condition is true. require 'ferrum' def wait_until(page, js, timeout: 10, interval: 0.1) deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout until page.evaluate(js) raise Ferrum::TimeoutError if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline sleep interval end end browser = Ferrum::Browser.new(timeout: 30) begin page = browser.create_page page.go_to(ENV.fetch('TARGET_URL', 'http://localhost:8909/js.html')) wait_until(page, "document.querySelector('#late')?.textContent.startsWith('JS-LATE-OK')") page.pdf(path: 'js-ferrum.pdf', format: :A4) ensure browser.quit end puts 'saved js-ferrum.pdf'
With the poll, the PDF contained the late text on every run.
Running Ferrum in a worker
To get the warm numbers, the browser must outlive the job. In Sidekiq or Solid Queue that means one browser per worker process, created lazily and reused:
- Create the browser once per process and keep it in a module-level variable.
- Open a page per job with
create_pageand close it in anensureblock, so a failed job does not leak tabs. - Restart the browser after a few hundred jobs, or when
browser.processis gone. Long-lived Chrome grows in memory. - Cap concurrency at the number of pages your memory allows. One Chrome with several tabs is cheaper than several Chromes.
Grover: the code
# Grover 1.2: each call runs Node + Puppeteer, which launches and closes Chrome. # Needs `npm install puppeteer` in the app root (Grover requires it from node_modules). require 'grover' png = Grover.new('https://example.com', viewport: { width: 1280, height: 800 }, full_page: true, wait_until: 'networkidle0').to_png File.binwrite('example-grover.png', png) pdf = Grover.new('https://example.com', format: 'A4', print_background: true).to_pdf File.binwrite('example-grover.pdf', pdf) puts 'saved example-grover.png and example-grover.pdf'
Grover has built-in waits. wait_for_function takes a JavaScript expression and waits until it is truthy, which fixed our late-text page:
# Grover: wait for your own condition instead of trusting network idle. require 'grover' pdf = Grover.new(ENV.fetch('TARGET_URL', 'http://localhost:8909/js.html'), format: 'A4', wait_for_function: "document.querySelector('#late')?.textContent.startsWith('JS-LATE-OK')").to_pdf File.binwrite('js-grover.pdf', pdf) puts 'saved js-grover.pdf'
For Rails, the middleware is the reason many teams pick Grover. Add config.middleware.use Grover::Middleware in config/application.rb, and a request to /invoices/42.pdf renders /invoices/42 as a PDF. The README asks you to set ignore_path if you turn on the PNG or JPEG middleware, so asset requests are not converted.
Deployment differences
| Ferrum | Grover | |
|---|---|---|
| Ruby dependencies | ferrum gem (plus addressable, concurrent-ruby, websocket-driver) |
grover gem |
| Other runtimes | A Chrome or Chromium binary | Node.js, the puppeteer npm package, and Chrome (Puppeteer can download it) |
| Chrome location | Auto-detected, or browser_path: |
Puppeteer's download, or executable_path: |
| Docker | apt install chromium is enough |
Node plus Chromium, and often --no-sandbox launch args when running as root |
| Process model | You decide: per job, per worker, or shared | One Node and one Chrome per call |
| Crash handling | Your code: rescue Ferrum::Error, restart the browser |
Grover raises Grover::JavaScript::* errors from the Node side |
Grover's per-call model has one upside: nothing leaks between jobs. Ferrum's warm model needs the restart and cleanup rules listed above.
When to choose each
Choose Grover when your app already ships Node for assets, you need a PDF of a page you render yourself, and volume is low enough that 1.5 seconds per document does not matter.
Choose Ferrum when you need throughput, you want a Node-free image, or you need to interact with the page before capture. Ferrum is also the base of Cuprite, so a team that tests with Cuprite already knows its API.
Choose neither when you do not want Chrome in your app servers at all. That is the case we built RenderScreenshot for. The Ruby SDK makes the same PNG and PDF with one HTTP call per capture:
# RenderScreenshot Ruby SDK: the same PNG and PDF without a local Chrome. require 'renderscreenshot' client = RenderScreenshot.client(ENV.fetch('RS_API_KEY')) png = client.take(RenderScreenshot::TakeOptions.url('https://example.com').width(1280).height(800).full_page) File.binwrite('example-rs.png', png) pdf = client.take(RenderScreenshot::TakeOptions.url('https://example.com').format('pdf').pdf_paper_size('a4')) File.binwrite('example-rs.pdf', pdf) puts 'saved example-rs.png and example-rs.pdf'
We tested this snippet against a local mock of our API, not the live service. A hosted call adds network time, so a warm Ferrum on the same machine as your pages will still be faster per capture. What you remove is the Chrome process, its memory and its upgrades. The trade-offs for Rails apps are covered on the Rails screenshot page.
Get a free API key and add it as a third column to the benchmark. No credit card is needed.
Last tested 2026-10-15 with Ruby 4.0.7, ferrum 0.18.0, grover 1.2.10, Puppeteer 24.43.1 and Chrome for Testing 148.0.7778.97 on macOS (Apple M1 Pro).