Ruby on Rails Background Jobs

πŸš€ Ruby on Rails Background Jobs: Build Faster, Smarter & More Scalable Applications βš™οΈπŸ”₯

Modern applications are expected to respond instantly. Users don’t want to wait while your Rails application sends 1,000 emails, generates a PDF, processes a CSV, calls an external API, or performs a heavy database operation.

This is where Background Jobs become one of the most important tools in a Ruby on Rails developer’s toolbox.

Instead of making the user wait:

Request β†’ Heavy Work β†’ Response ❌

we can do:

Request β†’ Queue Job β†’ Immediate Response β†’ Worker Performs Work βœ…

Background jobs allow Rails applications to become faster, more reliable, scalable, and user-friendly.

ChatGPT Image Aug 26, 2026, 07_53_11 PM

Let’s understand them from the ground up. πŸ‘‡


🧠 What Is a Background Job?

A background job is a piece of work that Rails performs outside the normal web request-response cycle.

For example, imagine a user registers:

User
  ↓
POST /users
  ↓
Create User
  ↓
Send Welcome Email
  ↓
Generate Analytics
  ↓
Call External API
  ↓
Response

If every operation happens during the request, the user may wait several seconds.

Instead:

User
  ↓
POST /users
  ↓
Create User
  ↓
Enqueue Background Jobs
  ↓
Response immediately ⚑
       ↓
   Job Queue
       ↓
    Worker
       ↓
Heavy Processing

The user gets an immediate response while the expensive work happens separately.


🎯 Why Should You Use Background Jobs?

Not everything belongs inside a controller request.

Common background-job candidates

πŸ“§ Sending emails πŸ“± Sending notifications πŸ“„ Generating PDFs πŸ“Š Generating reports πŸ“₯ Processing CSV/Excel files πŸ–ΌοΈ Image/video processing πŸ”„ Calling third-party APIs πŸ’³ Payment reconciliation πŸ“¦ Inventory synchronization πŸ” Search indexing πŸ“ˆ Analytics processing 🧹 Cleanup tasks ⏰ Scheduled operations πŸ€– AI/ML processing

A simple rule:

If the user doesn’t need the result immediately, consider moving the work to a background job.


⚑ Background Jobs vs Normal Requests

Suppose you have:

def create
  @user = User.create!(user_params)

  UserMailer.welcome_email(@user).deliver_now

  generate_user_report(@user)

  redirect_to @user
end

The request is responsible for everything.

A better architecture:

def create
  @user = User.create!(user_params)

  UserMailerJob.perform_later(@user.id)
  GenerateUserReportJob.perform_later(@user.id)

  redirect_to @user
end

Now the request remains lightweight.


πŸ—οΈ Rails Active Job

Rails provides an abstraction called Active Job.

It gives you a common interface for creating and enqueueing background jobs.

Generate a job:

rails generate job SendWelcomeEmail

Rails creates something similar to:

class SendWelcomeEmailJob < ApplicationJob
  queue_as :default

  def perform(user_id)
    user = User.find(user_id)

    UserMailer.welcome_email(user).deliver_now
  end
end

Then enqueue it:

SendWelcomeEmailJob.perform_later(user.id)

That’s the basic idea.


πŸ”₯ perform_later vs perform_now

This distinction is extremely important.

perform_now

Executes immediately.

SendWelcomeEmailJob.perform_now(user.id)

The current process performs the job.

Request
  ↓
Job executes
  ↓
Request waits
  ↓
Response

perform_later

Places the job into the configured queue.

SendWelcomeEmailJob.perform_later(user.id)

Conceptually:

Request
  ↓
Enqueue Job
  ↓
Response ⚑

Worker
  ↓
Execute Job

For actual background processing, perform_later is normally what you want.


🧩 How Rails Background Jobs Work

A typical architecture looks like this:

              Rails Application
                     β”‚
                     β–Ό
               perform_later
                     β”‚
                     β–Ό
                 Job Queue
                     β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β–Ό                     β–Ό
       Worker 1              Worker 2
          β”‚                     β”‚
          β–Ό                     β–Ό
      Execute Job           Execute Job

The queue acts as a waiting area.

Workers continuously pick jobs from the queue and execute them.


πŸ› οΈ Different Background Job Backends

Active Job is the Rails interface.

The actual execution can be handled by different queueing systems.

Popular choices include:

πŸ‡ Sidekiq

One of the most popular choices in the Rails ecosystem.

class ProcessOrderJob < ApplicationJob
  queue_as :default

  def perform(order_id)
    order = Order.find(order_id)

    # Process order
  end
end

Sidekiq commonly uses Redis for queue/state management.


🐘 Solid Queue

Modern Rails applications can also use Solid Queue, which stores job data in the database rather than requiring Redis.

This can simplify infrastructure when your application already relies heavily on a relational database.

Conceptually:

Rails
 ↓
Active Job
 ↓
Solid Queue
 ↓
Database
 ↓
Worker

🧡 Other Adapters

Depending on your infrastructure, Active Job can work with different queue backends.

The important architectural idea is:

Your Application
       ↓
   Active Job
       ↓
 Queue Backend
       ↓
    Worker

This abstraction prevents your application code from being tightly coupled to one particular job system.


πŸ“§ Example 1 β€” Sending Email

Imagine an e-commerce application.

After an order is placed:

OrderConfirmationJob.perform_later(order.id)

Job:

class OrderConfirmationJob < ApplicationJob
  queue_as :mailers

  def perform(order_id)
    order = Order.find(order_id)

    OrderMailer.confirmation(order).deliver_now
  end
end

Now your checkout request doesn’t need to wait for the email provider.

πŸš€ Checkout becomes faster.


πŸ“Š Example 2 β€” Generating Reports

Suppose your application generates a large sales report.

Don’t do this:

def generate
  SalesReport.generate
end

Instead:

GenerateSalesReportJob.perform_later(current_user.id)

Job:

class GenerateSalesReportJob < ApplicationJob
  queue_as :reports

  def perform(user_id)
    user = User.find(user_id)

    report = SalesReport.generate(user)

    ReportMailer.completed(user, report).deliver_now
  end
end

The user can continue using the application while the report is generated.


πŸ“₯ Example 3 β€” CSV Processing

Imagine uploading:

customers.csv

containing 500,000 records.

Never process all of them inside the upload request.

Instead:

ProcessCustomersCsvJob.perform_later(file_id)

Then:

class ProcessCustomersCsvJob < ApplicationJob
  queue_as :imports

  def perform(file_id)
    file = ImportedFile.find(file_id)

    CSV.foreach(file.path, headers: true) do |row|
      Customer.create!(
        name: row["name"],
        email: row["email"]
      )
    end
  end
end

For very large imports, you should also consider batching:

rows.each_slice(1000) do |batch|
  # Process 1,000 records
end

This reduces memory pressure.


🌐 Example 4 β€” External API Calls

Suppose your application synchronizes products with another service.

Instead of:

ExternalService.sync_product(product)

inside a controller:

SyncProductJob.perform_later(product.id)

Job:

class SyncProductJob < ApplicationJob
  queue_as :integrations

  def perform(product_id)
    product = Product.find(product_id)

    ExternalService.sync(product)
  end
end

This is especially useful because external APIs can be:

  • slow 🐌
  • temporarily unavailable
  • rate-limited 🚦
  • unreliable
  • dependent on network conditions

πŸ”„ Example 5 β€” Retry Failed Jobs

One of the biggest advantages of background processing is the ability to retry failures.

For example:

class SyncProductJob < ApplicationJob
  retry_on Net::ReadTimeout, wait: 5.seconds, attempts: 3

  def perform(product_id)
    product = Product.find(product_id)

    ExternalService.sync(product)
  end
end

Conceptually:

Attempt 1 ❌
   ↓
Wait
   ↓
Attempt 2 ❌
   ↓
Wait
   ↓
Attempt 3 βœ…

This is extremely useful for temporary failures.


⚠️ Don’t Retry Everything

This is a critical principle.

Some failures are permanent.

For example:

Product.find(product_id)

may raise:

ActiveRecord::RecordNotFound

Retrying it repeatedly won’t magically create the missing record.

Therefore:

Retry transient failures, not permanent failures.

Good retry candidates:

  • network timeout
  • temporary API outage
  • database connection interruption
  • rate limiting

Poor retry candidates:

  • invalid input
  • missing required data
  • permanent business-rule failure

⏰ Example 6 β€” Delayed Jobs

You don’t always want a job immediately.

For example:

Send a reminder 24 hours after signup.

You can schedule it:

WelcomeReminderJob
  .set(wait: 24.hours)
  .perform_later(user.id)

Or:

WelcomeReminderJob
  .set(wait_until: 1.day.from_now)
  .perform_later(user.id)

This enables workflows such as:

Signup
  ↓
24 hours
  ↓
Reminder
  ↓
7 days
  ↓
Follow-up

πŸ”₯ Example 7 β€” Multiple Jobs

Suppose an order is completed.

You may need to:

  1. Send confirmation email
  2. Update inventory
  3. Notify warehouse
  4. Generate invoice
  5. Update analytics

Instead of creating one giant job:

ProcessEverythingJob

create focused jobs:

OrderConfirmationJob.perform_later(order.id)
UpdateInventoryJob.perform_later(order.id)
NotifyWarehouseJob.perform_later(order.id)
GenerateInvoiceJob.perform_later(order.id)
UpdateAnalyticsJob.perform_later(order.id)

This provides better isolation and observability.


🎚️ Queue Priorities

Not all jobs are equally important.

For example:

queue_as :critical

versus:

queue_as :low

You might design:

critical
 β”œβ”€β”€ Payment processing
 └── Security notifications

default
 β”œβ”€β”€ Emails
 └── Order processing

low
 β”œβ”€β”€ Analytics
 └── Cleanup

This prevents low-value tasks from blocking important work.


🧠 The Perfect Background Job

A good background job follows a few important principles.

1️⃣ Keep Jobs Small

Bad:

MegaJob.perform_later

containing 1,000 lines of business logic.

Better:

ImportJob
 ↓
ValidateJob
 ↓
ProcessJob
 ↓
NotifyJob

Small jobs are easier to:

  • test
  • retry
  • monitor
  • debug
  • scale

2️⃣ Pass IDs Instead of Large Objects

Prefer:

SendInvoiceJob.perform_later(invoice.id)

rather than:

SendInvoiceJob.perform_later(invoice)

Why?

Because database records can change between enqueueing and execution.

For example:

10:00 AM
Job created
Invoice = $100

10:05 AM
Invoice updated
Invoice = $150

10:10 AM
Job executes

The job can fetch the latest state:

invoice = Invoice.find(invoice_id)

This is generally safer and produces smaller job payloads.


3️⃣ Make Jobs Idempotent πŸ”

One of the most important background-job concepts.

An idempotent operation can safely be executed multiple times without producing unintended duplicate effects.

Imagine:

SendInvoiceJob.perform(order.id)

If it runs twice, you don’t want:

Invoice #1001
Invoice #1001

or two payments.

Instead, design operations around uniqueness/state.

For example:

return if order.invoice_generated?

Then:

generate_invoice(order)
order.update!(invoice_generated: true)

The goal is:

Running a job twice should not corrupt your system.


4️⃣ Expect Jobs to Fail

Distributed systems fail.

Networks fail.

APIs fail.

Databases fail.

Servers restart.

Therefore:

class ExampleJob < ApplicationJob
  def perform(id)
    # Work
  end
end

should be designed with the assumption that failure will happen.

Think:

Success β†’ Great
Failure β†’ Retry / Record / Alert

not:

Failure β†’ Application is broken πŸ’₯

5️⃣ Avoid Long Transactions

Don’t hold database transactions while performing slow external operations.

Bad:

ActiveRecord::Base.transaction do
  order.update!(status: "processing")

  ExternalApi.call

  order.update!(status: "completed")
end

The external API might take 20 seconds.

That means your database transaction remains open unnecessarily.

Prefer smaller transaction boundaries.


6️⃣ Handle Race Conditions

Background workers can execute jobs concurrently.

For example:

Worker A β†’ Update Stock
Worker B β†’ Update Stock

Both might read:

stock = 10

and produce incorrect results.

Use appropriate database-level protections such as:

product.with_lock do
  product.update!(
    stock_quantity: product.stock_quantity - quantity
  )
end

For critical data, rely on database constraints and locking rather than assuming jobs execute sequentially.


7️⃣ Make Jobs Observable πŸ‘€

A production job shouldn’t become a black box.

You should be able to answer:

  • What job failed?
  • Why did it fail?
  • How many times did it retry?
  • How long did it take?
  • Which record was being processed?
  • Is the queue growing?
  • Which queue is overloaded?

Useful metrics include:

Queue latency
Execution time
Success rate
Failure rate
Retry count
Dead jobs
Queue size

Observability turns:

β€œSomething is slow.”

into:

β€œThe report queue has 18,000 jobs and its median execution time increased from 2s to 15s.”

That’s actionable.


πŸ” 8️⃣ Never Put Secrets in Job Arguments

Avoid:

SendApiJob.perform_later(
  user.id,
  "my-secret-api-key"
)

Job arguments can potentially be stored in queue infrastructure.

Instead, retrieve credentials securely through your application’s secret-management mechanism.


πŸ’Ύ 9️⃣ Be Careful With Large Arguments

Avoid passing:

huge_array
huge_json
large_binary_file

through a queue.

Instead:

ProcessFileJob.perform_later(file.id)

Then the worker retrieves the file.

This keeps queue payloads small and efficient.


πŸ§ͺ Testing Background Jobs

Background jobs deserve proper tests.

Example:

RSpec.describe SendWelcomeEmailJob do
  it "sends the welcome email" do
    user = create(:user)

    expect {
      described_class.perform_now(user.id)
    }.to change { ActionMailer::Base.deliveries.count }.by(1)
  end
end

Also test:

βœ… Success

Job executes successfully

❌ Failure

Expected exception occurs

πŸ”„ Retry

Transient failure β†’ retry

πŸ›‘οΈ Idempotency

Run twice β†’ no duplicate side effect

🧩 Edge cases

Missing record
Invalid data
External API unavailable

🚨 Common Background Job Mistakes

❌ 1. Doing everything in one job

EverythingJob

Eventually becomes impossible to maintain.


❌ 2. No retry strategy

Temporary failures become permanent failures.


❌ 3. Infinite retries

Some errors will never recover.


❌ 4. Non-idempotent jobs

Retries can create duplicate payments, emails, records, etc.


❌ 5. Passing entire ActiveRecord objects

Pass identifiers instead.


❌ 6. Huge job payloads

Queues aren’t designed to carry massive datasets.


❌ 7. Ignoring queue priorities

A low-priority analytics job shouldn’t prevent payment processing.


❌ 8. No monitoring

A background system without monitoring is a silent failure machine.


πŸ† A Production-Ready Example

Consider a seed/agriculture inventory application.

When a large order is placed:

class ProcessOrderJob < ApplicationJob
  queue_as :orders

  retry_on Net::ReadTimeout, wait: 10.seconds, attempts: 3

  def perform(order_id)
    order = Order.find(order_id)

    return if order.processed?

    Order.transaction do
      update_inventory(order)
      generate_invoice(order)

      order.update!(
        status: "processed",
        processed_at: Time.current
      )
    end

    SendOrderConfirmationJob.perform_later(order.id)
    SyncAccountingJob.perform_later(order.id)
  end

  private

  def update_inventory(order)
    order.items.each do |item|
      item.product.with_lock do
        item.product.update!(
          stock_quantity:
            item.product.stock_quantity - item.quantity
        )
      end
    end
  end

  def generate_invoice(order)
    InvoiceGenerator.call(order)
  end
end

This demonstrates several important concepts:

βœ… Small focused job βœ… ID-based arguments βœ… Retry strategy βœ… Idempotency βœ… Database transaction βœ… Row locking βœ… Follow-up jobs βœ… Separation of responsibilities


🧱 A Good Background Job Architecture

For a mature Rails application, think in layers:

Controller
    β”‚
    β–Ό
Application Service
    β”‚
    β–Ό
Background Job
    β”‚
    β–Ό
Domain/Business Logic
    β”‚
    β”œβ”€β”€ Database
    β”œβ”€β”€ External APIs
    β”œβ”€β”€ Email
    └── Storage

The job should coordinate work rather than becoming a giant business-logic container.

For example:

class GenerateReportJob < ApplicationJob
  def perform(report_id)
    report = Report.find(report_id)

    Reports::Generator.call(report)
  end
end

Now the actual business logic lives somewhere testable and reusable.


πŸ“ˆ Scaling Background Jobs

Imagine:

100 jobs/day

One worker may be enough.

But eventually:

100,000 jobs/day

Now you need:

             Queue
               β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
      β–Ό        β–Ό        β–Ό
   Worker 1 Worker 2 Worker 3
      β”‚        β”‚        β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β–Ό
           Database

Horizontal worker scaling allows your application to process more jobs concurrently.

But remember:

More workers β‰  unlimited performance.

Your database, external APIs, Redis/queue backend, CPU and memory can become bottlenecks.


πŸ”₯ Advanced Principle: Backpressure

Suppose your application receives:

10,000 jobs/minute

but workers can process only:

5,000 jobs/minute

Your queue will continuously grow.

Incoming: 10,000/min
Processing: 5,000/min

Queue ↗️↗️↗️↗️

You need to understand:

  • queue throughput
  • worker capacity
  • concurrency
  • database capacity
  • API rate limits

This is backpressure.

A scalable system doesn’t simply add workers blindly.


🧠 Background Jobs Mental Model

Remember this simple framework:

πŸ“¨ Enqueue

MyJob.perform_later(id)

🎯 Queue

Which job should execute?

πŸ‘· Worker

Who executes it?

πŸ”„ Retry

What happens when it fails?

πŸ›‘οΈ Idempotency

What happens if it runs twice?

πŸ‘€ Observability

How do I know what happened?

πŸ“ˆ Scaling

How do I process more jobs?

Master these seven concepts and you’ll understand the foundation of production-grade background processing.


πŸ† The Perfect Background Job Checklist

Before deploying a job, ask:

  • Does this work really need to happen synchronously?
  • Is the job small and focused?
  • Am I passing IDs instead of large objects?
  • Is the job idempotent?
  • What happens if it fails?
  • Which errors should be retried?
  • Is there a maximum retry limit?
  • Could duplicate execution cause damage?
  • Are database transactions kept short?
  • Are race conditions handled?
  • Is the queue appropriate?
  • Is the job observable?
  • Are sensitive values protected?
  • Are large payloads avoided?
  • Is the job tested?
  • Can the worker scale horizontally?
  • What happens when the external API is unavailable?

If you can answer all of these confidently, you’re thinking like a production Rails engineer, not just someone who knows how to call perform_later. πŸš€


🌎 Real-World Architecture

A mature Rails application might eventually look like:

                    🌐 Users
                       β”‚
                       β–Ό
                 πŸš‚ Rails App
                       β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚            β”‚            β”‚
          β–Ό            β–Ό            β–Ό
       Database      Job Queue    Object Storage
                       β”‚
             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
             β–Ό         β–Ό         β–Ό
          Worker 1  Worker 2  Worker 3
             β”‚         β”‚         β”‚
             β–Ό         β–Ό         β–Ό
          Emails     Reports    APIs
             β”‚         β”‚         β”‚
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β–Ό
                 πŸ“Š Monitoring

This is how a simple Rails application can evolve into a highly scalable system.


πŸš€ Final Thoughts

Background jobs aren’t simply a way to make a Rails request faster.

They are a fundamental part of designing reliable distributed applications.

The real goal isn’t:

β€œPut slow code into a job.”

The real goal is:

Design work so that it can execute asynchronously, safely, repeatedly, observably, and at scale.

Master:

Active Job β†’ Queues β†’ Workers β†’ Retries β†’ Idempotency β†’ Concurrency β†’ Observability β†’ Scaling

and you’ll have a much stronger understanding of how modern Rails applications operate in production. πŸ’Ž


πŸ’‘ The Golden Rule

A perfect background job assumes that failure, retries, duplication, concurrency, and delays are normalβ€”not exceptional.

Build for those realities, and your Rails application becomes faster for users, easier to operate, and dramatically more resilient. πŸš‚βš‘πŸ”₯

© Lakhveer Singh Rajput - Blogs. All Rights Reserved.