Ferrum vs Grover: Ruby Screenshots and PDFs Measured

Ferrum vs Grover for Ruby and Rails: same Chrome, same pages, measured cold and warm. Grover took about 1.6 s per call, a warm Ferrum page about 0.2 s.

By Simon Chiu · Updated

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_page and close per capture. This is a long-running worker. One warm-up capture was not counted.
  • Grover: Grover.new(...).to_png or .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:

  1. 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.
  2. 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.
  3. 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's wait_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_page and close it in an ensure block, so a failed job does not leak tabs.
  • Restart the browser after a few hundred jobs, or when browser.process is 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).

Frequently asked questions

Does Grover need Node.js?
Yes. Grover runs a Node script that uses Puppeteer, so your server needs Node and the puppeteer npm package in the app root. Ferrum talks to Chrome directly from Ruby and needs only a Chrome or Chromium binary.
Do Ferrum and Grover produce different images?
Not when they drive the same Chrome. In our test with Chrome 148, the 1200x630 card and the full-page capture were byte-for-byte identical from both libraries.
Which one is faster for many screenshots?
Ferrum, if you keep one browser open and open a new page per job. That took about 190 to 250 ms per capture in our test. Grover starts Node and Chrome for every call, which took about 1.3 to 1.8 seconds.
Can I get a PDF of a Rails page by adding .pdf to the URL?
With Grover, yes. Add Grover::Middleware to your Rails middleware stack and requests ending in .pdf return a PDF of that page. PNG and JPEG middleware are off by default. Ferrum has no middleware; you write the controller action.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.