SnapRender
  1. Home
  2. Blog
  3. Website Screenshots in Rails: Net::HTTP, Active Job, and Active Storage

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.

Try it on your URL

Live capture, no signup. You get the screenshot and the exact API call that produced it.

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

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.

Grab a free API key

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.

Or skip the setup entirely

200 screenshots a month free. First render in under a minute.