Menu
railsrubysidekiqtutorialscreenshot-api

Rails ActiveJob + Sidekiq Screenshot Pipeline Tutorial

SnapSharp Team·April 26, 2026·4 min read

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_KEY

For Heroku, Fly, or other platforms, use env vars instead:

SNAPSHARP_API_KEY=sk_live_YOUR_API_KEY

Step 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
end

Now 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:migrate

app/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
end

has_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
end

Three things to notice:

  1. retry_on Snapsharp::ServerError, wait: :polynomially_longer — Rails 7.1+ has built-in polynomial backoff. Combine with attempts: 5 and you get retries at ~3s, 18s, 83s, 258s, 627s.
  2. retry_on Snapsharp::RateLimitError separately — wait 60 seconds per attempt to let the bucket refill.
  3. 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
end

Add the route:

# config/routes.rb
resources :sites do
  member do
    post :recapture
  end
end

Saving 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 }
end

When 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'
end

Route it:

get '/blog/:slug/og.png', to: 'posts#og_image', as: :post_og_image

In 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-screenshots

In config/environments/production.rb:

config.active_storage.service = :amazon

Now 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:
  - default

Or use a per-job rate limit with sidekiq-throttled:

class SiteScreenshotJob < ApplicationJob
  sidekiq_throttle threshold: { limit: 30, period: 1.minute }
  # ...
end

Idempotency

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
  # ...
end

Requires 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 sidekiq

Set the env var:

heroku config:set SNAPSHARP_API_KEY=sk_live_...
heroku addons:create heroku-redis:mini
heroku ps:scale worker=1

Fly.io

In fly.toml:

[processes]
  app = "bundle exec puma -C config/puma.rb"
  worker = "bundle exec sidekiq"

[[services]]
  processes = ["app"]
  internal_port = 3000

Set 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

Rails ActiveJob + Sidekiq Screenshot Pipeline Tutorial — SnapSharp Blog