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
useis outermost and itscallreturns 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 inon_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?| Concern | Mechanism | Note |
|---|---|---|
| Retry | retry_on, sidekiq_options retry | Defaults to 25 attempts with exponential backoff |
| Idempotency | Guard on persisted state | At-least-once delivery means duplicates |
| Uniqueness | sidekiq-unique-jobs lock | Prevents duplicates, not concurrent execution mistakes |
| Ordering | One queue, concurrency 1 | Sidekiq is not ordered in general |
| Backpressure | Queue weight and worker count | An 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
SIGTERMand 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.
Related
Rails essentials: MVC, ActiveRecord and routing Deployment, performance and next steps
Last refreshed 2026-09-18.