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
renderscreenshot1.0.1, in a newrails newapp on SQLite.bin/rails testpassed 8 tests.bin/jobsprocessed five capture jobs, one of them after a 429 withRetry-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_concurrencyinstead 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 insolid_queue_blocked_executions. The per-minute rate limit is per account, and all your keys share it. Keepto: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 inX-RateLimit-Limit.rescue_fromfor 429, notretry_on.retry_ontakes a fixed or computed wait but cannot read the exception. The gem raisesRateLimitErrorwithretry_aftertaken from theRetry-Afterheader, andretry_jobcan use it. The toomanyrequests page explains the headers.- A status check on
ValidationError. The gem maps every 422 exceptrender_failedtoValidationError, so a page that timed out and a malformed URL raise the same class. The code tells them apart. A 400 such asinvalid_urlre-raises, and Solid Queue records it insolid_queue_failed_executions, where you can inspect it or retry it after a fix. StringIOstraight into Active Storage.takereturns a binaryString, 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 &, 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
- To drive Chrome from Ruby yourself, website screenshots in Ruby compares Ferrum, Grover and this API, and Ferrum vs Grover has the timings. For PDFs, Rails HTML to PDF compares wicked_pdf, Grover and Prawn.
- Gem source and issues are at github.com/Render-Screenshot/rs-ruby.
Get a free API key, put it in your credentials and enqueue CapturePreviewJob from the console. The free plan includes 150 credits every month.