02 · Background Jobs (Sidekiq)¶
Some work shouldn't happen inside a web request: sending an email, resizing an image, generating a report. Making the user wait on that work (or worse, losing it if the request times out) is bad UX. Sidekiq is the most widely used Ruby background job library — it pushes a job description onto a Redis-backed queue, and a separate worker process pulls jobs off that queue and runs them, entirely decoupled from the web request that enqueued them.
This module doesn't require Redis to be running — the examples use a
tiny in-process stand-in with the identical method shape (perform_async
/ perform) so you can see the pattern and run the code, without a
Redis dependency. The real Sidekiq API is called out at each step.
The job class shape¶
A real Sidekiq job looks like this:
class SendWelcomeEmailJob
include Sidekiq::Job
def perform(user_email)
UserMailer.welcome(user_email).deliver_now
end
end
# Enqueue it from anywhere in your app — returns immediately:
SendWelcomeEmailJob.perform_async("alice@example.com")
perform_async serializes the arguments (they must be simple JSON-safe
types: strings, numbers, arrays, hashes — not full Ruby objects) into
Redis and returns instantly. A separate Sidekiq worker process, running
independently of your web server, picks the job up and calls perform
on a fresh instance of the job class.
Simulating it without Redis¶
# A tiny in-process stand-in for Sidekiq's queue, used here so the
# example runs without Redis. Real Sidekiq exposes the identical
# perform_async / perform method shape via `include Sidekiq::Job`.
class InlineQueue
def self.jobs
@jobs ||= []
end
def self.enqueue(klass, *args)
jobs << [klass, args]
end
def self.drain
jobs.each { |klass, args| Object.const_get(klass).new.perform(*args) }
jobs.clear
end
end
class SendWelcomeEmailJob
# In real Sidekiq: include Sidekiq::Job
def self.perform_async(*args)
InlineQueue.enqueue(name, *args)
end
def perform(user_email)
puts "Sending welcome email to #{user_email}"
end
end
SendWelcomeEmailJob.perform_async("alice@example.com")
puts "Queued #{InlineQueue.jobs.size} job(s), nothing sent yet"
InlineQueue.drain
Captured output:
perform_async only enqueues — the "Sending welcome email" line
doesn't print until InlineQueue.drain actually runs the job, which
mirrors real Sidekiq: the web request that called perform_async
returns to the user immediately, while the email actually sends whenever
a worker process next picks it up (usually milliseconds later, but
decoupled).
Retries — jobs are expected to fail sometimes¶
External calls (payment gateways, third-party APIs) fail transiently. Sidekiq retries a failed job automatically, with exponential backoff, up to a configurable number of times before giving up and moving it to a "dead" queue for manual inspection. The idea, simulated here manually:
class FlakyJob
MAX_RETRIES = 3
def perform(attempt = 1)
puts "Attempt #{attempt}"
raise "simulated network error" if attempt < 3
puts "Succeeded on attempt #{attempt}"
rescue => e
if attempt < MAX_RETRIES
puts "Failed: #{e.message}, retrying..."
perform(attempt + 1)
else
puts "Giving up after #{attempt} attempts"
end
end
end
FlakyJob.new.perform
Captured output:
Attempt 1
Failed: simulated network error, retrying...
Attempt 2
Failed: simulated network error, retrying...
Attempt 3
Succeeded on attempt 3
In real Sidekiq, you don't write this retry loop by hand — a job that
raises is automatically re-enqueued by Sidekiq's middleware with
increasing delay (sidekiq_options retry: 5 configures the count). The
manual version here exists purely to make the concept concrete before
you rely on the framework to do it invisibly.
Idempotency — the property retries demand¶
Because a job might run more than once (a retry after a partial failure, or Sidekiq's at-least-once delivery guarantee under rare failure conditions), a well-written job must be idempotent: running it twice produces the same end state as running it once.
# NOT idempotent — running twice charges the customer twice
def perform(order_id)
Order.find(order_id).charge_card!
end
# Idempotent — a repeat run is a no-op
def perform(order_id)
order = Order.find(order_id)
return if order.charged?
order.charge_card!
end
The guard clause (return if order.charged?) is the whole difference —
cheap to write, and the reason production Sidekiq jobs almost always
check "has this already happened?" before doing anything with a
real-world side effect.
Queues and priority¶
Sidekiq supports multiple named queues (default, critical, low),
processed with configurable priority:
class SendWelcomeEmailJob
include Sidekiq::Job
sidekiq_options queue: "low"
end
class ChargeCardJob
include Sidekiq::Job
sidekiq_options queue: "critical"
end
A worker pool configured to check critical before low ensures
time-sensitive jobs (charging a card) don't sit behind a backlog of
low-priority ones (sending a marketing email) during a traffic spike.
Background-job-specific traps¶
- Passing whole ActiveRecord objects as arguments instead of IDs.
Sidekiq serializes arguments to JSON, so an ActiveRecord object gets
mangled or fails to serialize; the correct pattern is always
SomeJob.perform_async(user.id), thenUser.find(user_id)insideperform— the record might have changed (or been deleted) between enqueue and execution, and re-fetching guarantees you see current data. - Assuming a job runs exactly once. Sidekiq's default is at-least-once delivery — a crash between finishing work and acknowledging the job can cause a legitimate re-run. Non-idempotent jobs (charging twice, sending duplicate emails) are a very common production bug class traced back to this.
- Long-running jobs blocking a worker slot. A job that takes 10 minutes ties up one of a fixed number of worker threads/processes for that whole time — break large batch work into smaller enqueued chunks instead of one giant job.
- Forgetting jobs need their own error visibility. A raised exception in a background job doesn't show up anywhere a user or developer would normally look (no HTTP response, no browser console) unless you wire up error tracking (Sidekiq's built-in retry/dead-queue UI, or a service like Sentry) — silent job failures are a classic "why didn't the email ever send" investigation.
- Testing jobs by actually running Sidekiq/Redis in CI when
Sidekiq::Testing.fake!(or an equivalent inline stub) lets you assert a job was enqueued with the right arguments without any Redis dependency at all — much faster and more deterministic.
How It Actually Works¶
A background job framework (Sidekiq, Resque, etc.) is fundamentally a
producer/consumer queue implemented over a shared store (Redis) rather than
an in-process Queue: enqueuing a job serializes the job class name and
arguments to JSON and pushes it onto a Redis list or sorted set; the
returned control flow to your web request is immediate because the actual
work happens in a completely separate OS process (the Sidekiq worker),
which polls that same Redis structure in a loop, pops a job, deserializes
it, and calls YourJob.new.perform(*args) — ordinary method dispatch, no
different from calling it directly. Because the worker process is separate,
it has its own GVL, its own heap, and its own object space entirely; job
arguments must be JSON-serializable precisely because no Ruby object
references (which only make sense within one process's heap) can cross
that process boundary — this is also why passing an ActiveRecord object
directly to a job is a common bug: only its ID survives serialization, and
the worker must re-fetch the record from the database, potentially seeing
different data than existed when the job was enqueued.
Cheat sheet¶
| Task | Sidekiq API |
|---|---|
| Make a class a job | include Sidekiq::Job |
| Enqueue a job | SomeJob.perform_async(arg1, arg2) |
| Enqueue for later | SomeJob.perform_in(5.minutes, arg1) |
| Define the work | def perform(arg1, arg2); ...; end |
| Set retry count | sidekiq_options retry: 5 |
| Assign a queue | sidekiq_options queue: "critical" |
| Test without Redis | Sidekiq::Testing.fake! |
| Assert enqueued in a test | expect(SomeJob.jobs.size).to eq(1) |
Exercise¶
- Extend the
InlineQueuesimulation with aperform_in(delay, klass, *args)method that stores a "run at" timestamp alongside the job, and adrain(now: Time.now)that only runs jobs whose scheduled time has passed — demonstrate a job scheduled 10 seconds out not running when drained immediately. - Write an idempotent
ChargeOrderJob(using a plain in-memoryOrderstruct with achargedboolean) and prove that callingperformtwice only charges once, printing a message either way so you can see the guard clause firing on the second call. - Simulate the retry-then-give-up path: a job that always fails, retried 3 times, printing each attempt and a final "moved to dead queue" message instead of raising all the way up to crash the process.