A Rails app that needs screenshots usually has the same shape as a Rails app that needs image processing: a model with an image attachment, a background job that populates it, and an admin button to trigger re-captures. Replace the in-process image processor (or worse, headless Chrome) with the SnapSharp API, and you skip the gnarliest infrastructure decisions.
This tutorial walks through that pattern end to end. ActiveJob with Sidekiq, ActiveStorage for attachments, retries with exponential backoff, and a clean service object pattern.
Prerequisites
- Ruby 3.2+ and Rails 7.1+.
- A Sidekiq + Redis setup (
bundle add sidekiq redis). - ActiveStorage configured (default in Rails 7).
- A free SnapSharp API key from snapsharp.dev/sign-up.
Step 1: install the SDK
The Ruby SDK is on RubyGems as snapsharp. Add it to your Gemfile:
gem 'snapsharp'Then bundle install.
Configure the API key in config/credentials.yml.enc (run rails credentials:edit):
snapsharp:
api_key: sk_live_YOUR_API_KEYFor Heroku, Fly, or other platforms, use env vars instead:
SNAPSHARP_API_KEY=sk_live_YOUR_API_KEYStep 2: a service object wrapping the SDK
Service objects are the Rails-idiomatic place for external API clients. Create app/services/snapsharp_service.rb:
class SnapsharpService
class << self
def client
@client ||= Snapsharp::Client.new(api_key: api_key)
end
def capture_screenshot(url, **options)
client.screenshot(url, **default_options.merge(options))
end
def generate_og_image(template:, variables:, **options)
client.og_image(template: template, variables: variables, **options)
end
private
def api_key
Rails.application.credentials.dig(:snapsharp, :api_key) ||
ENV.fetch('SNAPSHARP_API_KEY')
end
def default_options
{ width: 1280, height: 720, format: 'png', block_ads: true }
end
end
endNow any code that needs SnapSharp calls SnapsharpService.capture_screenshot(url). The credential lookup and defaults are centralized.
Step 3: a Site model with ActiveStorage
rails g model Site url:string title:string status:string
rails db:migrateapp/models/site.rb:
class Site < ApplicationRecord
enum :status, { pending: 'pending', ready: 'ready', failed: 'failed' }, default: :pending
has_one_attached :screenshot
validates :url, presence: true, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
after_create_commit :enqueue_screenshot_capture
after_update_commit :enqueue_screenshot_capture, if: :saved_change_to_url?
private
def enqueue_screenshot_capture
SiteScreenshotJob.perform_later(self)
end
endhas_one_attached gives us free local-or-S3-or-R2 storage. The after_create_commit and after_update_commit callbacks queue a background job whenever a Site is created or its URL changes — no manual perform_later calls scattered across controllers.
Step 4: an ActiveJob with Sidekiq retry semantics
app/jobs/site_screenshot_job.rb:
class SiteScreenshotJob < ApplicationJob
queue_as :default
retry_on Snapsharp::ServerError, wait: :polynomially_longer, attempts: 5
retry_on Snapsharp::RateLimitError, wait: 60.seconds, attempts: 3
discard_on Snapsharp::ClientError do |job, error|
site = job.arguments.first
Rails.logger.warn("Permanent failure for site #{site.id}: #{error.message}")
site.update(status: :failed)
end
def perform(site)
site.update(status: :pending)
image_bytes = SnapsharpService.capture_screenshot(
site.url,
width: 1280,
height: 720,
format: 'png',
)
site.screenshot.attach(
io: StringIO.new(image_bytes),
filename: "site-#{site.id}.png",
content_type: 'image/png',
)
site.update(status: :ready)
Rails.logger.info("Captured screenshot for site #{site.id}")
end
endThree things to notice:
retry_on Snapsharp::ServerError, wait: :polynomially_longer— Rails 7.1+ has built-in polynomial backoff. Combine withattempts: 5and you get retries at ~3s, 18s, 83s, 258s, 627s.retry_on Snapsharp::RateLimitErrorseparately — wait 60 seconds per attempt to let the bucket refill.discard_on Snapsharp::ClientError— 4xx errors are permanent. No point retrying a 400 invalid URL.
Step 5: trigger captures from controllers
app/controllers/sites_controller.rb:
class SitesController < ApplicationController
before_action :set_site, only: [:show, :recapture]
def index
@sites = Site.where(status: :ready).order(updated_at: :desc).page(params[:page])
end
def show
end
def create
@site = Site.new(site_params)
if @site.save
redirect_to @site, notice: 'Site created. Screenshot will be captured shortly.'
else
render :new, status: :unprocessable_entity
end
end
def recapture
SiteScreenshotJob.perform_later(@site)
redirect_to @site, notice: 'Screenshot capture queued.'
end
private
def set_site
@site = Site.find(params[:id])
end
def site_params
params.require(:site).permit(:url, :title)
end
endAdd the route:
# config/routes.rb
resources :sites do
member do
post :recapture
end
endSaving a Site creates a record, then the after_create_commit enqueues a job. The user sees the page immediately. Sidekiq picks up the job within seconds.
Step 6: views and Turbo for live updates
app/views/sites/show.html.erb:
<turbo-frame id="site_<%= @site.id %>">
<h1><%= @site.title %></h1>
<p><a href="<%= @site.url %>" target="_blank"><%= @site.url %></a></p>
<% if @site.ready? && @site.screenshot.attached? %>
<%= image_tag @site.screenshot,
alt: "Screenshot of #{@site.title}",
loading: 'lazy',
style: 'max-width:100%' %>
<% elsif @site.failed? %>
<p class="error">Screenshot capture failed.</p>
<%= button_to 'Retry', recapture_site_path(@site), method: :post %>
<% else %>
<%= turbo_stream_from @site %>
<p>Capturing screenshot…</p>
<% end %>
</turbo-frame>Broadcast updates from the model:
# app/models/site.rb
class Site < ApplicationRecord
# ...
after_update_commit -> { broadcast_replace_to self }
endWhen Sidekiq finishes the job and the Site is updated to ready, Turbo Streams pushes the new HTML to anyone watching. The user sees the screenshot appear without refresh.
Step 7: dynamic OG images for blog posts
If you're running a Rails blog (Jumpstart, Avo, Mastodon, anything), you probably want per-post OG cards. Add an OG action to your posts controller:
# app/controllers/posts_controller.rb
def og_image
post = Post.find_by!(slug: params[:slug])
image = SnapsharpService.generate_og_image(
template: 'blog-post',
variables: {
title: post.title,
author: post.author,
date: post.published_at.strftime('%Y-%m-%d'),
tag: post.tags.first || '',
},
width: 1200,
height: 630,
)
expires_in 1.day, public: true
send_data image, type: 'image/png', disposition: 'inline'
endRoute it:
get '/blog/:slug/og.png', to: 'posts#og_image', as: :post_og_imageIn your post view's head:
<% content_for :head do %>
<meta property="og:title" content="<%= @post.title %>">
<meta property="og:description" content="<%= @post.excerpt %>">
<meta property="og:image" content="<%= post_og_image_url(@post) %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<% end %>When LinkedIn or Twitter scrapes the post URL, it fetches og.png, hits your controller, gets a SnapSharp-generated PNG. See OG image best practices for design tips.
Step 8: production patterns
Using ActiveStorage with S3
In config/storage.yml:
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: eu-west-1
bucket: my-rails-screenshotsIn config/environments/production.rb:
config.active_storage.service = :amazonNow site.screenshot.url returns a CDN-friendly S3 URL automatically.
Sidekiq concurrency
The default Sidekiq concurrency (10) is fine for low-volume captures. If you're hitting SnapSharp's rate limits, drop it:
# config/sidekiq.yml
:concurrency: 5
:queues:
- defaultOr use a per-job rate limit with sidekiq-throttled:
class SiteScreenshotJob < ApplicationJob
sidekiq_throttle threshold: { limit: 30, period: 1.minute }
# ...
endIdempotency
If the same Site is updated multiple times in quick succession, you'll queue multiple jobs. Two concurrent runs both attaching screenshots is wasteful. Add a debounce with unique_for:
class SiteScreenshotJob < ApplicationJob
sidekiq_options unique_for: 5.minutes
# ...
endRequires sidekiq-unique-jobs gem. Within a 5-minute window, only one job for a given Site ID runs.
Step 9: deploying
Heroku
Add the Sidekiq worker dyno to your Procfile:
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiqSet the env var:
heroku config:set SNAPSHARP_API_KEY=sk_live_...
heroku addons:create heroku-redis:mini
heroku ps:scale worker=1Fly.io
In fly.toml:
[processes]
app = "bundle exec puma -C config/puma.rb"
worker = "bundle exec sidekiq"
[[services]]
processes = ["app"]
internal_port = 3000Set secrets via fly secrets set. Spin up Fly Redis with fly redis create.
Common pitfalls
Pitfall 1: synchronous SnapSharp calls in controllers. A 3-second screenshot blocks a Puma thread. Use ActiveJob always.
Pitfall 2: forgetting after_create_commit instead of after_create. The _commit variant runs after the DB transaction commits. Otherwise Sidekiq picks up the job and queries the DB before the row is visible. Race condition every time.
Pitfall 3: storing binary in DB. Don't add a screenshot column of type binary to the model. Use ActiveStorage. Postgres rows full of megabytes destroy query performance.
Pitfall 4: missing retry strategy. Without retry_on, a transient SnapSharp 503 fails permanently. Always specify retry rules per error class.
Pitfall 5: rate limit cascades. If you import 5,000 sites at once, Sidekiq queues 5,000 jobs and bombs SnapSharp. Use throttling or Sidekiq's concurrency: 1 queue for the import job.
Final code
Five files:
app/services/snapsharp_service.rb— service wrapper.app/jobs/site_screenshot_job.rb— ActiveJob with retries.app/models/site.rb— model with attachment and callbacks.app/controllers/sites_controller.rb— REST endpoints.app/controllers/posts_controller.rb— OG image action.
Around 150 lines of integration code, plus boilerplate Rails generators.
Conclusion
Rails has the cleanest async-job ergonomics of any major web framework, and SnapSharp is a drop-in replacement for headless-Chrome-in-a-Heroku-buildpack. ActiveJob handles retries, ActiveStorage handles attachments, and Turbo handles live UI updates. You write a service object, a job, and a model — the rest is Rails.
Next steps: explore the Ruby tutorial, read about webhooks, or look at Laravel queue patterns for the PHP equivalent.
Related: Ruby tutorial · Pricing · Custom OG templates