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.
Try it on your URL
Live capture, no signup. You get the screenshot and the exact API call that produced it.
This is the exact API call that made the image above. Swap in your key and it runs anywhere.
200 renders a month free.
You hit the free demo limit here. The full tool can verify you are human and keep going.
Continue in the full toolA 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
Or skip the setup entirely
Everything in this guide is one GET request with SnapRender. No browser to babysit, no timeouts to tune.
200 screenshots a month free. First render in under a minute.
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.