Blog 3 min read

Website Screenshots in Rails: Net::HTTP, Active Job, and Active Storage

Capture screenshots from a Rails app with no headless Chrome in the container: a controller action with Net::HTTP, an Active Job with retries, attaching the result with Active Storage, and caching.

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.

PDF

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.

Screenshots as an API call

200 free renders a month. Paid plans start at $9.

Sign up free