Background jobs and the Ruby web stack

Rack and its middleware, Sinatra for small services, Puma workers and threads, Sidekiq and Active Job, retries and idempotency.

Rack, Sinatra and Puma

# a Rack application is any object that responds to call(env)
class Api
  def call(env)
    case [env["REQUEST_METHOD"], env["PATH_INFO"]]
    in ["GET", "/health"] then [200, { "content-type" => "application/json" }, ['{"ok":true}']]
    in ["GET", "/books"]  then [200, { "content-type" => "application/json" }, [Book.all.to_json]]
    else [404, { "content-type" => "text/plain" }, ["not found"]]
    end
  end
end

# config.ru
require_relative "app"
use Rack::CommonLogger
use Rack::ContentLength
run Api.new
# Sinatra: a Rack app with a DSL
require "sinatra/base"

class BooksApp < Sinatra::Base
  set :host_authorization, permitted_hosts: []

  get "/books/:id" do
    content_type :json
    Book.find(params["id"]).to_json
  rescue ActiveRecord::RecordNotFound
    halt 404, { error: "not found" }.to_json
  end
end

# config/puma.rb
workers Integer(ENV.fetch("WEB_CONCURRENCY", 2))
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS", 5))
threads threads_count, threads_count
preload_app!
on_worker_boot { ActiveRecord::Base.establish_connection }
  • Middleware wraps the application like an onion: the first use is outermost and its call returns last.
  • Puma runs several processes, each with its own thread pool. Workers multiply memory, threads multiply concurrency only for I/O-bound work.
  • preload_app! shares memory between forked workers via copy-on-write, and requires reconnecting the database in on_worker_boot.
  • Size threads to your database connection pool. Thirty Puma threads with a pool of five is a queue that looks like a slow app.
💡
Set RAILS_MAX_THREADS and the database pool to the same number. Mismatched values are the most common cause of ConnectionTimeoutError under load.

Sidekiq and Active Job

class ChargeOrderJob < ApplicationJob
  queue_as :critical

  sidekiq_options retry: 5, dead: true, lock: :until_executed

  discard_on ActiveJob::DeserializationError

  retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 5
  retry_on Stripe::RateLimitError, wait: 5.seconds, attempts: 8

  def perform(order)
    return if order.charged?          # idempotent guard

    result = PaymentGateway.charge(order)
    order.mark_charged!(result.id)
  end
end

ChargeOrderJob.perform_later(order)
ChargeOrderJob.set(wait: 10.minutes).perform_later(order)
ChargeOrderJob.perform_later(order) if something_changed?
ConcernMechanismNote
Retryretry_on, sidekiq_options retryDefaults to 25 attempts with exponential backoff
IdempotencyGuard on persisted stateAt-least-once delivery means duplicates
Uniquenesssidekiq-unique-jobs lockPrevents duplicates, not concurrent execution mistakes
OrderingOne queue, concurrency 1Sidekiq is not ordered in general
BackpressureQueue weight and worker countAn unbounded queue hides an outage

Jobs are arguments, not state. Pass identifiers and re-load records inside perform; serialising a large object graph makes the payload brittle and breaks on deploy.

Running the queue in production

bundle exec sidekiq -C config/sidekiq.yml -e production

# config/sidekiq.yml
# :concurrency: 10
# :queues:
#   - [critical, 4]
#   - [default, 2]
#   - [mail, 1]
# :max_retries: 5
  • Separate queues by latency requirement, not by class. A mail queue with weight 1 stops a slow SMTP server from delaying critical work.
  • Watch queue latency, not just failures: a growing latency is the earliest visible symptom of a backlog.
  • Deploy job classes before enqueuing them; an in-flight job referencing a removed class crashes when the worker picks it up.
  • Kill workers with SIGTERM and wait: Sidekiq finishes in-flight jobs and returns unstarted ones to Redis.

FAQ

Active Job or Sidekiq worker directly?
Active Job when you want the backend to be swappable and the framework-integrated API for retries and callbacks. The Sidekiq worker class when you need Sidekiq-specific features such as batched push or precise queue control.
Why did my job run twice?
Because delivery is at-least-once: a worker can die after the side effect but before acknowledging. Make the job idempotent with a persisted guard, and where possible move the state change into the same database transaction as the record it affects.

Rails essentials: MVC, ActiveRecord and routing Deployment, performance and next steps

Last refreshed 2026-09-18.