Ferrum, Cuprite and Grover all work, and all require a Chrome binary inside your Rails deployment, with the memory profile that implies on a small dyno or container. If what you need is "give me an image of this URL", a screenshot API keeps the browser out of your stack and Rails only makes an HTTP request.
A small client
# app/services/snap_render.rb
require "net/http"
require "uri"
class SnapRender
ENDPOINT = URI("https://app.snap-render.com/v1/screenshot")
Result = Struct.new(:status, :body, :content_type)
def self.capture(url, format: "png", full_page: true, cache_ttl: 86_400)
uri = ENDPOINT.dup
uri.query = URI.encode_www_form(
url: url,
format: format,
full_page: full_page.to_s,
block_cookie_banners: "true",
cache_ttl: cache_ttl
)
req = Net::HTTP::Get.new(uri)
req["X-API-Key"] = Rails.application.credentials.snaprender_api_key
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) { |http| http.request(req) }
Result.new(res.code.to_i, res.body, res["Content-Type"])
end
end
Screenshots as an API call
Send a GET request, get a PNG back. Ads and cookie banners blocked by default.
200 free renders a month. Paid plans start at $9.
A controller action
class ScreenshotsController < ApplicationController
def show
url = params.require(:url)
return head :bad_request unless url.start_with?("http://", "https://")
result = SnapRender.capture(url)
return render json: { error: "capture failed" }, status: :bad_gateway unless result.status == 200
expires_in 1.day, public: true
send_data result.body, type: result.content_type, disposition: "inline"
end
end
/screenshots?url=https://example.com now returns a full-page PNG. The 60-second read timeout protects Puma threads from pages that never settle.
Attaching with Active Storage
class Bookmark < ApplicationRecord
has_one_attached :screenshot
end
result = SnapRender.capture(bookmark.url, format: "jpeg")
if result.status == 200
bookmark.screenshot.attach(
io: StringIO.new(result.body),
filename: "#{bookmark.id}.jpeg",
content_type: "image/jpeg"
)
end
Any Active Storage service (disk, S3, GCS, Azure) works without changes.
Doing it in a job
class CaptureBookmarkJob < ApplicationJob
queue_as :default
retry_on SnapRender::RateLimited, wait: 1.minute, attempts: 5
retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 3
def perform(bookmark)
result = SnapRender.capture(bookmark.url, format: "jpeg")
raise SnapRender::RateLimited if result.status == 429
raise "capture failed: #{result.status}" unless result.status == 200
bookmark.screenshot.attach(io: StringIO.new(result.body), filename: "#{bookmark.id}.jpeg", content_type: "image/jpeg")
end
end
Add class RateLimited < StandardError; end inside SnapRender. A 429 is the per-minute burst or the monthly quota; waiting a minute handles the first, and the second is visible in the dashboard. For hundreds of URLs, the batch endpoint (up to 50 per call, webhook on completion) is simpler than a job per page.
Caching
cache_ttl on the request makes repeat captures free: the API serves them from its cache and they do not count against your quota. On the Rails side, Rails.cache.fetch("shot:#{url}", expires_in: 1.day) around the capture avoids repeating the round trip.
format: "pdf" returns a page image inside a PDF, suited to archives, not to text-searchable documents. Rendering your own ERB output as an image or PDF is a POST with the HTML body instead of a URL; the HTML to PDF API page has the details.
Related: Ruby quickstart, batch screenshots, scheduled captures.
Frequently asked questions
Do I need Ferrum, Cuprite or Grover for this?
No. Those drive a local Chrome. Here the browser runs behind an API, and Rails needs only Net::HTTP from the standard library.
How do I attach the screenshot to a model?
With Active Storage: has_one_attached :screenshot on the model, then attach the response body as an IO with a filename and content type, as the example shows.
What about credentials?
Store the key in Rails encrypted credentials (config/credentials.yml.enc) or an environment variable. The example reads Rails.application.credentials.snaprender_api_key.
How should a job react to a 429?
Treat it as a temporary condition and retry with a delay; Active Job's retry_on handles that. Persistent 429s mean the monthly quota is used up.