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.
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:
- Send confirmation email
- Update inventory
- Notify warehouse
- Generate invoice
- 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.