You've built your Rails app. It works. But something's wrong.
When a user signs up, they wait 5 seconds for the welcome email to send. When someone uploads a file, the page freezes until it processes. When an invoice generates, the user stares at a spinner wondering if anything happened.
These tasks don't need to happen right now. They just need to happen eventually.
That's what background jobs are for.
What Are Background Jobs?
Background jobs let your app handle time-consuming tasks outside of the normal request-response cycle.
The user requests something. Your app says I'll take care of it
and immediately responds. Meanwhile, a separate process does the actual work.
The user moves on. The work gets done. Everyone is happy.
What Should Go in a Background Job?
| Task Type | Examples | Why Background |
|---|---|---|
| Email sending | Welcome emails, password resets, invoices | Email APIs can be slow |
| API calls | Webhooks, third-party integrations | Network latency is unpredictable |
| File processing | Image resizing, PDF generation, CSV imports | CPU-intensive work |
| Notifications | Slack messages, push notifications | Multiple external calls |
| Cleanup tasks | Deleting old records, archiving data | Doesn't need user waiting |
| Report generation | Weekly summaries, analytics | Can take seconds or minutes |
What Should NOT Go in a Background Job?
| Task Type | Why Not |
|---|---|
| Database writes that the user needs immediately | User expects to see their change now |
| Validation that affects response | User needs to know if their input is valid |
| Anything that must succeed for the request to complete | If the job fails, the user wouldn't know |
Rails 8: Solid Queue Built In
Rails 8 changed the game. It includes Solid Queue as the default background job adapter.
No more Redis requirement. No more separate services to manage. Just a database table and a worker process.
Why Solid Queue Matters
| Before Rails 8 | After Rails 8 |
|---|---|
| Needed Redis or Sidekiq for production | Works with just PostgreSQL |
| Extra infrastructure to manage | One less dependency |
| Additional hosting costs | Lower monthly bill |
| More things that could break | Simpler deployment |
For most apps, Solid Queue is all you need.
Setting Up Solid Queue
It's already there if you're on Rails 8. Just generate the migration:
rails solid_queue:install
rails db:migrate
Run the worker in production:
bin/jobs
That's it. You're ready to queue background jobs.
Writing Your First Job
Generate a Job
rails generate job welcome_email
This creates app/jobs/welcome_email_job.rb:
class WelcomeEmailJob < ApplicationJob
queue_as :default
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome_email(user).deliver_now
end
end
Queue the Job
In your controller:
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
# This runs in the background
WelcomeEmailJob.perform_later(@user.id)
redirect_to @user, notice: "User created. Welcome email on its way!"
else
render :new
end
end
end
The user gets an immediate response. The email sends in the background.
What Happens Behind the Scenes
- Your app writes a job to the database table
- The Solid Queue worker picks it up
- The worker executes the
performmethod - The job is marked as completed or failed
The user never waits.
Different Queues for Different Priorities
Not all jobs are equal. Some need to run immediately. Others can wait.
Define Queues
class UrgentJob < ApplicationJob
queue_as :high_priority
# ...
end
class NormalJob < ApplicationJob
queue_as :default
# ...
end
class CleanupJob < ApplicationJob
queue_as :low_priority
# ...
end
Run Different Queues Separately
# Run only high priority jobs
bin/jobs --queue high_priority
# Run multiple queues with priorities
bin/jobs --queue high_priority,default,low_priority
Why This Matters
| Queue | Purpose | Example |
|---|---|---|
| High priority | User is waiting | Password reset email |
| Default | Normal background work | Welcome email, webhooks |
| Low priority | Maintenance | Database cleanup, old data archiving |
Your high priority queue can run every second. Your low priority queue can run once an hour.
Real-World Job Examples
Welcome Email with Tracking
class WelcomeEmailJob < ApplicationJob
queue_as :default
retry_on Net::SMTPError, wait: 5.minutes, attempts: 3
def perform(user_id)
user = User.find(user_id)
# Track when we started
user.update(welcome_email_sent_at: Time.current)
# Send the email
UserMailer.welcome_email(user).deliver_now
# Log success
Rails.logger.info("Welcome email sent to #{user.email}")
rescue => e
Rails.logger.error("Failed to send welcome email: #{e.message}")
raise # Trigger retry
end
end
Image Processing
class AvatarProcessingJob < ApplicationJob
queue_as :default
def perform(user_id, uploaded_file)
user = User.find(user_id)
# Resize the uploaded image
image = MiniMagick::Image.read(uploaded_file.read)
image.resize "200x200"
image.write("tmp/avatar_#{user_id}.jpg")
# Attach to user
user.avatar.attach(
io: File.open("tmp/avatar_#{user_id}.jpg"),
filename: "avatar.jpg",
content_type: "image/jpeg"
)
# Clean up
File.delete("tmp/avatar_#{user_id}.jpg")
end
end
Webhook Delivery
class WebhookDeliveryJob < ApplicationJob
queue_as :default
retry_on Net::ReadTimeout, wait: :exponentially_longer, attempts: 5
def perform(webhook_endpoint, payload)
response = HTTParty.post(
webhook_endpoint.url,
body: payload.to_json,
headers: { "Content-Type" => "application/json" }
)
if response.success?
Rails.logger.info("Webhook delivered to #{webhook_endpoint.url}")
else
Rails.logger.warn("Webhook failed: #{response.code}")
raise "Webhook failed with #{response.code}"
end
end
end
Data Export
class ExportReportJob < ApplicationJob
queue_as :low_priority
def perform(user_id, date_range)
user = User.find(user_id)
# Generate the report (this takes time)
report = ReportGenerator.new(user, date_range).generate
# Upload to S3
s3_key = "reports/#{user_id}/#{Date.today}.csv"
S3Client.put_object(bucket: "myapp-exports", key: s3_key, body: report.to_csv)
# Notify user via email
ReportMailer.ready(user, s3_key).deliver_now
end
end
Job Best Practices
1. Pass IDs, Not Objects
# Bad
UserMailer.welcome_email(user).deliver_later
# Good
WelcomeEmailJob.perform_later(user.id)
Why? The object might change between when you queue it and when it runs.
2. Make Jobs Idempotent
A job should be safe to run multiple times.
class ProcessOrderJob < ApplicationJob
def perform(order_id)
order = Order.find(order_id)
# Check if already processed
return if order.processed_at.present?
# Process the order
order.process!
# Mark as processed
order.update(processed_at: Time.current)
end
end
3. Set Timeouts
class SlowJob < ApplicationJob
queue_as :default
def perform(task)
Timeout.timeout(30.seconds) do
# This job should finish in 30 seconds
do_slow_work(task)
end
rescue Timeout::Error
Rails.logger.error("Job timed out after 30 seconds")
retry_job(wait: 10.seconds)
end
end
4. Use Unique Jobs When Needed
class SyncUserJob < ApplicationJob
# Only one instance of this job per user at a time
def self.unique_key(user_id)
"sync_user_#{user_id}"
end
def perform(user_id)
# Sync user data from external API
end
end
5. Log Everything
class ImportantJob < ApplicationJob
def perform(resource_id)
Rails.logger.info("Starting ImportantJob for resource #{resource_id}")
start_time = Time.current
# Do work
duration = Time.current - start_time
Rails.logger.info("Finished ImportantJob for resource #{resource_id} in #{duration}s")
end
end
Testing Background Jobs
Test That Jobs Are Enqueued
# spec/jobs/welcome_email_job_spec.rb
require "rails_helper"
RSpec.describe WelcomeEmailJob, type: :job do
let(:user) { create(:user) }
it "enqueues the job" do
expect {
WelcomeEmailJob.perform_later(user.id)
}.to have_enqueued_job(WelcomeEmailJob).with(user.id)
end
it "sends the email" do
expect {
WelcomeEmailJob.perform_now(user.id)
}.to change(ActionMailer::Base.deliveries, :count).by(1)
end
end
Test Controller Enqueues Jobs
# spec/requests/users_spec.rb
RSpec.describe "Users", type: :request do
it "enqueues welcome email on signup" do
expect {
post users_path, params: { user: attributes_for(:user) }
}.to have_enqueued_job(WelcomeEmailJob)
end
end
Test Job Behavior
# spec/jobs/welcome_email_job_spec.rb
RSpec.describe WelcomeEmailJob, type: :job do
describe "#perform" do
let(:user) { create(:user) }
it "sends welcome email to the correct user" do
perform_enqueued_jobs do
WelcomeEmailJob.perform_later(user.id)
end
email = ActionMailer::Base.deliveries.last
expect(email.to).to eq([user.email])
expect(email.subject).to include("Welcome")
end
it "handles missing user gracefully" do
expect {
WelcomeEmailJob.perform_now(999999)
}.not_to raise_error
end
end
end
Monitoring Jobs in Production
What to Track
| Metric | Why |
|---|---|
| Queue size | How many jobs are waiting? |
| Failed jobs | Are jobs failing consistently? |
| Job duration | Is a job taking too long? |
| Retry count | Is a job failing and retrying? |
Simple Monitoring with Rails Logs
class MonitoredJob < ApplicationJob
around_perform do |job, block|
start = Time.current
block.call
duration = Time.current - start
Rails.logger.info("#{job.class.name} took #{duration}s")
if duration > 10
# Alert on slow jobs
SlackNotifier.notify("Slow job: #{job.class.name} took #{duration}s")
end
end
end
Using Good Job Dashboard (if using Good Job gem)
# config/routes.rb
mount GoodJob::Engine => "/good_job" if Rails.env.production?
When to Upgrade from Solid Queue
Solid Queue handles most apps perfectly. But at high scale, you might need more.
Signs You Need an Upgrade
| Sign | What It Means |
|---|---|
| Thousands of jobs per second | Solid Queue's database polling becomes a bottleneck |
| Need for complex scheduling | Cron-like jobs, future-dated jobs |
| Job dependencies | One job must run after another |
| Real-time job processing | Need sub-second latency |
Upgrade Paths
| Option | Best For | Complexity |
|---|---|---|
| Good Job | PostgreSQL with better performance | Low |
| Sidekiq | High throughput, many jobs | Medium |
| Redis + Sidekiq | Massive scale, complex workflows | High |
Sidekiq Setup
# Gemfile
gem "sidekiq", "~> 7.0"
gem "redis", "~> 5.0"
bundle install
# config/application.rb
config.active_job.queue_adapter = :sidekiq
# app/jobs/welcome_email_job.rb
class WelcomeEmailJob < ApplicationJob
queue_as :default
def perform(user_id)
# Same code, now runs on Sidekiq
end
end
Common Job Patterns
Chain Jobs
class ProcessOrderJob < ApplicationJob
def perform(order_id)
order = Order.find(order_id)
# Process payment
PaymentJob.perform_later(order_id)
# Update inventory
InventoryJob.perform_later(order_id)
# Send confirmation
OrderConfirmationJob.perform_later(order_id)
end
end
Scheduled Jobs
# config/application.rb
config.active_job.queue_adapter = :solid_queue
# Schedule a job to run later
WelcomeEmailJob.set(wait: 1.day).perform_later(user.id)
# Schedule at a specific time
WelcomeEmailJob.set(wait_until: Date.tomorrow.noon).perform_later(user.id)
Periodic Jobs (with cron)
# config/schedule.rb (using whenever gem)
every 1.day, at: "2am" do
runner "CleanupJob.perform_later"
end
every :monday, at: "9am" do
runner "WeeklyReportJob.perform_later"
end
Job with Progress Tracking
class LongRunningJob < ApplicationJob
def perform(export_id)
export = Export.find(export_id)
export.update(progress: 0)
total = export.items.count
export.items.each_with_index do |item, index|
process_item(item)
percent = ((index + 1) * 100) / total
export.update(progress: percent)
end
export.update(completed_at: Time.current)
end
end
Real-World Example: E-commerce Order Flow
Here's how a complete order flow uses multiple jobs:
class OrderJob < ApplicationJob
def perform(order_id)
order = Order.find(order_id)
# Charge the customer
PaymentJob.perform_later(order_id)
# Update inventory
InventoryJob.perform_later(order_id)
# Send order confirmation
OrderMailer.confirmation(order).deliver_later
# Schedule review email for 7 days later
ReviewReminderJob.set(wait: 7.days).perform_later(order_id)
# Update analytics
AnalyticsJob.perform_later(order_id)
# Notify admin Slack channel
SlackNotifierJob.perform_later("New order ##{order.id}: $#{order.total}")
end
end
Each of these jobs can:
- Run independently
- Retry on failure
- Be monitored separately
- Scale individually
Summary
| Job Adapter | Best For | Infrastructure |
|---|---|---|
| Solid Queue (Rails 8) | Most apps, simplicity | PostgreSQL only |
| Good Job | Better performance, same DB | PostgreSQL |
| Sidekiq | High scale, complex jobs | Redis + PostgreSQL |
Checklist for Adding a New Job
- [ ] Does the task need to be real-time? If not, make it a job.
- [ ] Pass IDs, not objects
- [ ] Make the job idempotent (safe to retry)
- [ ] Add error handling and retries
- [ ] Log job start and finish
- [ ] Test that the job is enqueued
- [ ] Test the job's behavior
- [ ] Set an appropriate queue
- [ ] Monitor in production
When to Start Using Jobs
| User Scale | Job Strategy |
|---|---|
| 0 - 100 users | Start with deliver_later for emails only |
| 100 - 1,000 users | Add Solid Queue, move slow API calls |
| 1,000 - 10,000 users | Queue all email, file processing, webhooks |
| 10,000+ users | Consider Sidekiq, add monitoring, schedule periodic jobs |
Background jobs are not optional for a production app. They're essential.
Start with Solid Queue. It's built into Rails 8 and works great. When you outgrow it, you'll know. And you'll have options.