· Tutorials · By Simon Chiu

Website Screenshots in Ruby: Ferrum, Grover or an API

Website screenshots in Ruby with Ferrum, Grover or an API: tested code for viewport, full page, dark mode and cookies, measured file sizes, troubleshooting.

To take a screenshot of a web page in Ruby, drive Chrome with the Ferrum gem. It needs no Node.js and no ChromeDriver:

require 'ferrum'

browser = Ferrum::Browser.new(timeout: 30)
begin
  page = browser.create_page
  page.set_viewport(width: 1280, height: 800)
  page.go_to('https://example.com')
  page.screenshot(path: 'example.png')
ensure
  browser.quit
end

The rest of this tutorial covers the recipes you need next: exact sizes, full pages, elements, waits, dark mode, cookies, formats and running Chrome in production. It also covers Grover, why IMGKit is a dead end, and the same capture as one API call. For speed figures, read Ferrum vs Grover, which timed both gems on the same Chrome. This page does not repeat those numbers.

Tested with: Ruby 4.0.7 on macOS, Google Chrome 155.0.8059.80, ferrum 0.19.1, grover 1.2.10 with puppeteer 25.14.0, and renderscreenshot 1.0.1. Every Ferrum and Grover listing ran against https://example.com on 2026-10-18. The API listing ran against a local stub of our API, not the live service. The Dockerfile and the IMGKit section were not run.

Which tool fits

Ferrum Grover IMGKit (wkhtmltoimage) RenderScreenshot API
Talks to Chrome over CDP, from Ruby Node + Puppeteer, then Chrome Qt WebKit binary HTTPS
Needs on your server Chrome Node, the puppeteer package, Chrome wkhtmltoimage Nothing
Modern CSS (grid, gap, variables) Yes Yes No Yes
PDF Yes Yes, plus Rack middleware No (separate wkhtmltopdf) Yes
Browser lifetime You keep it open New Node and Chrome per call New process per call Not your concern
Best for Many captures from one app Teams that already run Node Nothing new No browser to install or patch

Setup

# Gemfile
gem 'ferrum', '~> 0.19'
gem 'grover', '~> 1.2'          # only if you use Grover
gem 'renderscreenshot', '~> 1.0' # only for the API section

Grover also needs Puppeteer next to your app: npm install puppeteer@25. Ferrum finds an installed Chrome or Chromium on its own. Set BROWSER_PATH if it is somewhere unusual.

Ferrum recipes

All the recipes below come from one script that we ran three times. Each one reuses the page from the first.

Exact viewport and pixel density

page.set_viewport(width: 1200, height: 630, scale_factor: 2)
page.go_to('https://example.com')
page.screenshot(path: '[email protected]')

The file is 2400x1260: the viewport times the scale factor. Use set_viewport, not the window_size: option of Ferrum::Browser.new. window_size sets the outer window. With window_size: [1280, 800] and no viewport call, our PNG came out 1280x713, because the window's own frame takes the rest. See viewport parameters for how the API names the same settings.

Full page and a single element

page.screenshot(path: 'full.png', full: true)
page.screenshot(path: 'icon.png', selector: 'svg')

full: true measures the document and captures all of it. The selector form crops to the element's box; on example.com the svg icon gave a 44x44 PNG. If the selector matches nothing, Ferrum 0.19.1 raises Ferrum::JavaScriptError with Cannot read properties of null (reading 'getBoundingClientRect'), not a "not found" error. We hit this because example.com no longer has an <h1>. Check with page.at_css(selector) first if the element is optional. Lazy-loaded images and sticky headers need more work on long pages; the full-page screenshots guide covers both.

Waiting for the page

go_to returns when the page has finished loading its HTML and the resources it asked for at load time. That is too early for pages that fetch their content with JavaScript. Pick a wait by what the page does:

The page Wait with
Static HTML and CSS Nothing; go_to is enough
Loads data after load, then stops page.network.wait_for_idle(timeout: 5)
Shows a known element when ready A selector loop (below)
Keeps polling or streaming forever A selector loop; network idle never comes
Animates in A selector, then a short sleep

Ferrum has no wait_for_selector, so write one with a deadline:

def wait_for_selector(page, css, timeout: 10)
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
  until page.at_css(css)
    raise Ferrum::TimeoutError if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
    sleep 0.1
  end
end

The wait strategies guide explains the same choices for the API.

Dark mode, and why your Mac renders dark anyway

page.command('Emulation.setEmulatedMedia',
             features: [{ name: 'prefers-color-scheme', value: 'dark' }])

Ferrum has no helper for this, so the code sends the Chrome DevTools command directly. The surprise came before we set anything. The test Mac was in dark mode, and every screenshot of example.com came out with a #222222 background, including Grover's. Headless Chrome follows the operating system's colour scheme. The same code on a Linux server renders the light #eeeeee theme. If the result must not depend on the machine, set value: 'light' or 'dark' on every page. After we set light, both gems gave #eeeeee on the same Mac.

Formats, quality and file size

page.screenshot(path: 'vp.png', format: 'png')
page.screenshot(path: 'vp.jpeg', format: 'jpeg', quality: 80)
page.screenshot(path: 'vp.webp', format: 'webp', quality: 80)
b64 = page.screenshot(encoding: :base64)  # a String, no file

What we measured for a 1280x800 viewport of example.com in its dark theme. Bytes were identical on all three runs. The time is the screenshot call alone, with the page already loaded:

Format Bytes Time per call (3 runs)
PNG 81,121 42 to 70 ms
JPEG, quality 80 59,166 34 to 39 ms
WebP, quality 80 36,008 90 to 114 ms

WebP was the smallest and the slowest to encode on this page. A page with photos changes the ratios, so measure on your own URLs. encoding: :binary returns the bytes, which suits an upload to S3 or Active Storage. The format comparison goes into text sharpness and transparency.

Cookies and headers for logged-in pages

page.headers.set('X-Preview' => 'screenshot-bot')
page.cookies.set(name: 'session', value: ENV.fetch('SESSION_COOKIE'), domain: 'example.com')
page.go_to('https://example.com')

Set both before go_to. The cookie was visible to the page as document.cookie in our run. Read the session value from the environment and never from code. A better pattern is a short-lived token made for the screenshot user. The authenticated pages guide explains why.

A production shape for Ferrum

One Chrome per process, a fixed number of tabs, and a fresh context per capture:

require 'ferrum'

class Shooter
  def initialize(concurrency: 2)
    @browser = Ferrum::Browser.new(timeout: 30, process_timeout: 20,
                                   browser_options: { 'no-sandbox' => nil })
    @slots = Thread::SizedQueue.new(concurrency)
    concurrency.times { @slots << true }
  end

  def capture(url, width: 1280, height: 800, wait_for: nil, full: false)
    @slots.pop
    @browser.create_page(new_context: true) do |page|
      page.network.blocklist = [/doubleclick\.net/, /google-analytics\.com/]
      page.command('Emulation.setEmulatedMedia',
                   features: [{ name: 'prefers-color-scheme', value: 'light' }])
      page.set_viewport(width: width, height: height)
      page.go_to(url)
      wait_for_selector(page, wait_for) if wait_for
      page.screenshot(format: 'png', full: full, encoding: :binary)
    end
  ensure
    @slots << true
  end

  def quit = @browser.quit

  private

  def wait_for_selector(page, css, timeout: 10)
    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
    until page.at_css(css)
      raise Ferrum::TimeoutError if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
      sleep 0.1
    end
  end
end

We ran three captures on three threads with concurrency: 2, then a capture that waits for a missing selector. The three PNGs were saved, the last call raised Ferrum::TimeoutError, and no Chrome process was left after quit. The design choices:

  • new_context: true opens an incognito context per capture and closes it after the block, so one job's cookies never reach the next.
  • SizedQueue caps open tabs. Each tab is a renderer process, so a burst of jobs must not open fifty.
  • blocklist (an alias of blacklist= in Ferrum 0.19) drops requests to ad and analytics hosts. That makes pages faster and stops your captures from showing up in someone's analytics.
  • no-sandbox is only for containers that run as root. Leave it out anywhere else.
  • ensure with quit is required. A crashed Ruby process without it leaves Chrome running.

Chrome and fonts in Docker (not run)

FROM ruby:4.0-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk \
    && rm -rf /var/lib/apt/lists/*
ENV BROWSER_PATH=/usr/bin/chromium

A slim image has almost no fonts. Without the Noto packages, emoji and Chinese, Japanese and Korean text render as empty boxes. We did not build this image for this post. Consistent fonts in headless rendering covers font setup in more depth.

Grover

Grover sends your options to a Node script that runs Puppeteer:

require 'grover'

png = Grover.new(
  'https://example.com',
  viewport: { width: 1200, height: 630, deviceScaleFactor: 1 },
  wait_until: 'networkidle0',
  media_features: [{ name: 'prefers-color-scheme', value: 'light' }]
).to_png

pdf = Grover.new('https://example.com', format: 'A4', print_background: true).to_pdf

The PNG was 1200x630 and the PDF had one page. Grover converts snakecase option names to Puppeteer's camelCase, and an option it does not know is dropped without an error. Our first try used `emulatemediafeatures:, and the dark Mac screenshot did not change. The correct key ismediafeatures:. When a Grover option seems to do nothing, check its name in the gem'sprocessor.cjs. Eachto_png` starts Node and Chrome, which suits occasional PDFs more than a queue of thousands of screenshots.

IMGKit and wkhtmltoimage

IMGKit wraps wkhtmltoimage, which renders with a WebKit engine from Qt 4. The project's own status page says that engine has not been updated since 2012. CSS grid, flexbox gap and custom properties render wrong, so most pages built today look broken. We did not install it for this post. The wkhtmltoimage alternative page shows the evidence and has a drop-in replacement for IMGKit calls.

Troubleshooting

Symptom Cause Fix
PNG is shorter than window_size window_size is the outer window page.set_viewport(width:, height:)
JavaScriptError ... getBoundingClientRect selector: matched nothing Check page.at_css first, or fix the selector
Dark screenshots on a laptop, light on the server Chrome follows the OS colour scheme Set prefers-color-scheme explicitly
Grover option has no effect Unknown keys are dropped Check the camelCase name Puppeteer expects
Ferrum::ProcessTimeoutError at start-up Chrome is missing or too slow to start Set BROWSER_PATH, raise process_timeout:
Chrome exits at once inside Docker Sandbox needs privileges a root container lacks browser_options: { 'no-sandbox' => nil } in the container only
Boxes instead of emoji or CJK text Fonts are not installed Install fonts-noto-color-emoji and fonts-noto-cjk
Blank area where data should be Captured at load, before JavaScript fetched data network.wait_for_idle or a selector wait
Chrome processes pile up Exceptions skip quit ensure browser.quit; one browser per process
A logged-in job shows another user's page Shared default context create_page(new_context: true) per capture

The same capture with one API call

When you would rather not install or patch Chrome, send the same options to the API with the renderscreenshot gem:

require 'renderscreenshot'

client = RenderScreenshot::Client.new(ENV.fetch('RS_API_KEY'), timeout: 90)

options = RenderScreenshot::TakeOptions
  .url('https://example.com')
  .width(1280).height(800)
  .full_page
  .format('webp').quality(80)
  .wait_for_selector('svg')
  .block_cookie_banners
  .block_ads

attempts = 0
begin
  attempts += 1
  File.binwrite('example.webp', client.take(options))
rescue RenderScreenshot::RateLimitError => e
  raise if attempts >= 3
  sleep(e.retry_after || 5)
  retry
rescue RenderScreenshot::Error => e
  warn "#{e.http_status} #{e.code}: #{e.message} (request #{e.request_id})"
  exit 1
end

Against our stub, the gem sent viewport, capture.mode: full_page, output, wait.for_selector and block as nested JSON. It retried once after a 429 and exited with status 1 on a 400. The waits, the cookie banner blocking and the font set are handled on our side. The Ruby SDK reference lists every builder method. For a Rails app with Active Job, Active Storage and signed og:image tags, continue with the Rails screenshot guide.

Get a free API key and run the listing above with RS_API_KEY set. The free plan includes 150 credits every month.

Related in this topic