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,
ferrum0.19.1,grover1.2.10 withpuppeteer25.14.0, andrenderscreenshot1.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 |
| 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: trueopens an incognito context per capture and closes it after the block, so one job's cookies never reach the next.SizedQueuecaps open tabs. Each tab is a renderer process, so a burst of jobs must not open fifty.blocklist(an alias ofblacklist=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-sandboxis only for containers that run as root. Leave it out anywhere else.ensurewithquitis 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
- How to Take Website Screenshots with Code: 6 Ways Take a screenshot of a website programmatically: DevTools, the Chrome CLI, Puppeteer, Playwright, Selenium or an API. Timed runs and code for each.
- Selenium Screenshots in Python, Java and C# (Selenium 4) Take a screenshot with Selenium 4 in Python, Java and C#: page, element, exact viewport and PDF. Same test in three languages, timed, with the fixes.
- Playwright Screenshots: Tested Recipes and Pitfalls Take screenshots with Playwright: page.screenshot options, deviceScaleFactor, locators, masks, measured format sizes, auth, Linux fonts and production setup.
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Website Screenshots in Node.js: Puppeteer, Playwright, API Take website screenshots in Node.js 22 with TypeScript and ESM: Puppeteer, Playwright or one fetch call to an API, with measured install cost and tested code.
- Website Screenshots in Python: Selenium, Playwright, API Capture a website as an image in Python with Selenium, Playwright or an API: measured setup and per-shot time, async batches, and fixes for common failures.