Rails Screenshot API: Solid Queue, Active Storage, og:image

Take website screenshots and PDFs in Rails 8: a Solid Queue job with Retry-After handling, Active Storage attachments, signed og:image tags and Minitest fakes.

By Simon Chiu · Updated

This page builds screenshots into a Rails 8 app the way Rails 8 does background work: an Active Job on Solid Queue, the image stored with Active Storage, a signed og:image in the layout, a PDF from a view, and Minitest coverage with no network. Other languages are on the screenshot API code examples hub. For Ruby outside Rails, see the Ruby screenshot API guide. The gem's full method list is in the Ruby SDK reference.

Last tested: 2026-10-18 with Ruby 4.0.7, Rails 8.1.4, Solid Queue 1.7.0, Faraday 2.15.0 and renderscreenshot 1.0.1, in a new rails new app on SQLite. bin/rails test passed 8 tests. bin/jobs processed five capture jobs, one of them after a 429 with Retry-After: 1, against a local stub that returns the documented responses. We did not call the live API, and we did not deploy the app.

Install and configure

bundle add renderscreenshot
bin/rails credentials:edit
# config/credentials.yml.enc
renderscreenshot:
  api_key: rs_live_xxxxx        # server-side renders
  secret_key: rs_secret_xxxxx   # signs og:image URLs, never sent
  public_key_id: rs_pub_xxxxx   # goes into signed URLs

The gem needs Ruby 3.0 or later and uses Faraday. Keep one client per process. It holds a Faraday connection, and the test suite needs one place to swap it:

# app/services/screenshots.rb
module Screenshots
  mattr_writer :client

  def self.client
    @@client ||= begin
      creds = Rails.application.credentials.renderscreenshot || {}
      RenderScreenshot::Client.new(
        creds[:api_key] || ENV.fetch('RS_API_KEY'),
        signing_key: creds[:secret_key] || ENV['RS_SECRET_KEY'],
        public_key_id: creds[:public_key_id] || ENV['RS_PUBLIC_KEY_ID'],
        timeout: 90
      )
    end
  end
end

The 90-second timeout is longer than the API's maximum render wait of 60 seconds (wait options). The gem's default of 30 seconds cuts off slow pages that the API would still finish.

The capture job

A Link model gets a preview image when it is created:

# app/models/link.rb
class Link < ApplicationRecord
  has_one_attached :preview

  validates :url, presence: true, format: { with: %r{\Ahttps?://}i }

  after_create_commit -> { CapturePreviewJob.perform_later(self) }
end
# app/jobs/capture_preview_job.rb
class CapturePreviewJob < ApplicationJob
  queue_as :screenshots

  # Solid Queue: at most 2 captures at once across every worker and host.
  limits_concurrency to: 2, key: ->(*) { 'renderscreenshot' }, duration: 5.minutes

  retry_on RenderScreenshot::ServerError, RenderScreenshot::RenderFailedError,
           RenderScreenshot::TimeoutError, RenderScreenshot::ConnectionError,
           wait: :polynomially_longer, attempts: 4

  # 429: wait exactly as long as the API asks.
  rescue_from RenderScreenshot::RateLimitError do |error|
    raise error if executions >= 5
    retry_job wait: (error.retry_after || 30).seconds
  end

  # 422 target errors arrive as ValidationError. Some deserve a second try.
  TRANSIENT_TARGET_CODES = %w[timeout navigation_failed screenshot_failed].freeze

  rescue_from RenderScreenshot::ValidationError do |error|
    transient = error.http_status == 422 && TRANSIENT_TARGET_CODES.include?(error.code)
    raise error unless transient && executions < 3
    retry_job wait: 1.minute
  end

  discard_on ActiveJob::DeserializationError # the link was deleted first

  def perform(link)
    options = RenderScreenshot::TakeOptions
      .url(link.url)
      .preset('og_card')
      .format('webp')
      .block_cookie_banners

    image = Screenshots.client.take(options)

    link.preview.attach(io: StringIO.new(image), filename: "link-#{link.id}.webp",
                        content_type: 'image/webp')
  end
end

Why the job looks like this

  • limits_concurrency instead of a thread count. Solid Queue keeps a semaphore row in the queue database, so the limit holds across processes and servers. When we enqueued five jobs with no worker running, Solid Queue made two ready and put three in solid_queue_blocked_executions. The per-minute rate limit is per account, and all your keys share it. Keep to: small enough that captures stay under that limit, with room for your other API calls. The rate limits reference lists it per plan, and every response reports it in X-RateLimit-Limit.
  • rescue_from for 429, not retry_on. retry_on takes a fixed or computed wait but cannot read the exception. The gem raises RateLimitError with retry_after taken from the Retry-After header, and retry_job can use it. The toomanyrequests page explains the headers.
  • A status check on ValidationError. The gem maps every 422 except render_failed to ValidationError, so a page that timed out and a malformed URL raise the same class. The code tells them apart. A 400 such as invalid_url re-raises, and Solid Queue records it in solid_queue_failed_executions, where you can inspect it or retry it after a fix.
  • StringIO straight into Active Storage. take returns a binary String, so no temp file is needed. Active Storage also checks the bytes with Marcel and stores the type it detects.

Background-job rules that apply to every queue, such as idempotent keys and webhook callbacks, are in screenshots in background jobs. That guide uses Sidekiq. This page stays with what ships in Rails 8.

Give screenshots their own worker

# config/queue.yml (production)
production:
  workers:
    - queues: [default]
      threads: 3
    - queues: [screenshots]
      threads: 2
      polling_interval: 0.5

A separate worker stops a burst of captures from filling the threads that send emails. Each capture thread spends most of its time waiting on HTTP, so two threads match the to: 2 limit.

A signed og:image in the layout

A signed URL lets a social crawler load the card straight from GET /v1/screenshot. The URL holds your rs_pub_ ID and an HMAC signature. It never holds a key that can call the API. For the card template itself, the meta-tags gem and Minitest checks, see Rails OG images.

# app/helpers/og_image_helper.rb
module OgImageHelper
  # Snap the expiry to a week boundary: the URL stays identical for a week
  # and always has 7 to 14 days left (the API allows at most 30).
  def og_image_url_for(page_url)
    expires_at = Time.current.utc.beginning_of_week + 2.weeks
    options = RenderScreenshot::TakeOptions.url(page_url).preset('og_card')
    Screenshots.client.generate_url(options, expires_at: expires_at)
  end

  def og_image_meta_tags(page_url)
    safe_join([
      tag.meta(property: 'og:image', content: og_image_url_for(page_url)),
      tag.meta(property: 'og:image:width', content: 1200),
      tag.meta(property: 'og:image:height', content: 630),
      tag.meta(name: 'twitter:card', content: 'summary_large_image')
    ], "\n")
  end
end
<%# app/views/links/show.html.erb %>
<% content_for :head, og_image_meta_tags(link_url(@link)) %>

The default Rails 8 layout already has <%= yield :head %>. tag.meta writes each & as &amp;, which is correct HTML, and crawlers decode it back. Only GET options go into the signature. generate_url drops POST-only options such as wait_for_selector and block_trackers without an error, so check the GET endpoint reference before you add one. Each crawler fetch is a render, so add .cache_ttl(...) and .cache_available if you want repeat fetches to reuse an image (caching).

A PDF from a Rails view

The same client turns any view into a PDF. The server needs no wkhtmltopdf binary and no Chrome:

# app/controllers/reports_controller.rb
class ReportsController < ApplicationController
  def show
    @links = Link.order(:created_at)
    html = render_to_string(:show, layout: 'pdf', formats: [:html])

    pdf = Screenshots.client.take(
      RenderScreenshot::TakeOptions.html(html).format('pdf').pdf_paper_size('a4').pdf_print_background
    )

    send_data pdf, filename: "links-#{Date.current}.pdf", type: 'application/pdf', disposition: 'inline'
  rescue RenderScreenshot::Error => e
    Rails.logger.warn("PDF render failed: #{e.http_status} #{e.code} #{e.request_id}")
    head :service_unavailable
  end
end

Put the CSS inline in layouts/pdf.html.erb. The renderer receives a string, not your app, so a relative stylesheet_link_tag path points nowhere. This is a synchronous render inside a request, which suits short documents. For long reports, move the call into a job like the one above and attach the PDF instead. Paper sizes, margins and header templates are in the PDF parameters.

Test it with Minitest

Minitest 6 no longer bundles stub, and this setup does not need it. A small fake replaces the client:

# test/support/fake_screenshot_client.rb
class FakeScreenshotClient
  attr_reader :calls

  def initialize(*responses)
    @responses = responses
    @calls = []
  end

  def take(options)
    @calls << options.to_params
    response = @responses.shift
    response.is_a?(Exception) ? raise(response) : response
  end
end
# test/jobs/capture_preview_job_test.rb
require 'test_helper'
require_relative '../support/fake_screenshot_client'

class CapturePreviewJobTest < ActiveJob::TestCase
  setup do
    @link = Link.create!(url: 'https://example.com')
    clear_enqueued_jobs
  end

  teardown { Screenshots.client = nil }

  test 'attaches the rendered image' do
    fake = FakeScreenshotClient.new('RIFF....WEBP')
    Screenshots.client = fake

    CapturePreviewJob.perform_now(@link)

    assert @link.reload.preview.attached?
    assert_equal 'og_card', fake.calls.first[:preset]
  end

  test 'a 429 is retried after Retry-After seconds' do
    limited = RenderScreenshot::RateLimitError.new('slow down', http_status: 429,
                                                   code: 'too_many_requests', retry_after: 7)
    Screenshots.client = FakeScreenshotClient.new(limited)

    freeze_time do
      CapturePreviewJob.perform_now(@link)
      assert_enqueued_with job: CapturePreviewJob, at: 7.seconds.from_now
    end
  end

  test 'a 422 timeout is retried, a 400 is not' do
    timeout = RenderScreenshot::ValidationError.new('Page load timeout', http_status: 422, code: 'timeout')
    Screenshots.client = FakeScreenshotClient.new(timeout)
    CapturePreviewJob.perform_now(@link)
    assert_enqueued_jobs 1

    clear_enqueued_jobs
    bad = RenderScreenshot::ValidationError.new('bad url', http_status: 400, code: 'invalid_url')
    Screenshots.client = FakeScreenshotClient.new(bad)
    assert_raises(RenderScreenshot::ValidationError) { CapturePreviewJob.perform_now(@link) }
    assert_no_enqueued_jobs
  end
end

to_params gives the nested hash that the gem sends as JSON, so a test can check the exact request. For the helper, use a real client with fake rs_secret_test and rs_pub_test values. generate_url makes no request, so the test can travel to two days of one week, assert that both URLs are the same, and assert that neither contains a secret. The SDK testing guide adds contract tests against a fake server.

Where this fits

Get a free API key, put it in your credentials and enqueue CapturePreviewJob from the console. The free plan includes 150 credits every month.

Frequently asked questions

How do I take a screenshot of a URL in Ruby on Rails?
Add the renderscreenshot gem, build options with RenderScreenshot::TakeOptions.url(url).preset('og_card') and call client.take in an Active Job. take returns the image as a binary String, which you can attach with Active Storage.
How do I respect Retry-After in an Active Job?
The gem raises RenderScreenshot::RateLimitError on a 429 and sets retry_after from the header. Rescue it with rescue_from and call retry_job wait: error.retry_after.seconds, because retry_on with a fixed wait ignores the header.
Why is a 422 timeout raised as ValidationError in the Ruby gem?
The gem maps every 422 except render_failed to ValidationError. Target errors such as timeout and navigation_failed are 422s that are worth one more try, so check error.http_status and error.code before you discard the job.
Can Rails generate a PDF without wkhtmltopdf?
Yes. Render the view to a string with render_to_string, send it as html with format pdf, and stream the bytes with send_data. Chrome renders the PDF on our side, so the server needs no browser or wkhtmltopdf binary.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.