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

  1. Your app writes a job to the database table
  2. The Solid Queue worker picks it up
  3. The worker executes the perform method
  4. 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.