Rails Active Job Expert
Create robust, well-tested background jobs using Active Job with Solid Queue (the Rails 8 default).
Philosophy
Core Principles:
- Solid Queue is the default — Rails 8+ ships with Solid Queue. Use it. No Redis dependency needed.
- Jobs must be idempotent — Jobs can run more than once. Design for it.
- Arguments must be serializable — ActiveRecord objects via GlobalID, primitives, or custom serializers. Never pass arbitrary Ruby objects.
- Always handle failure — Every job needs
retry_onordiscard_on. No exceptions. - Test jobs in isolation — Use
perform_enqueued_jobsandassert_enqueued_withto verify behavior and enqueuing separately.
When To Use This Skill
- Creating new background jobs
- Moving slow work out of the request cycle (emails, API calls, data processing)
- Configuring Solid Queue (queues, workers, priorities, recurring tasks)
- Handling job retries, errors, and dead letters
- Testing job behavior and enqueuing
- Integrating ActionMailer with
deliver_later - Setting up bulk enqueuing, concurrency controls, or job continuations
Instructions
Step 1: Generate the Job
bin/rails generate job process_payment
# Creates:
# app/jobs/process_payment_job.rb
# test/jobs/process_payment_job_test.rb
With a specific queue:
bin/rails generate job process_payment --queue critical
Step 2: Define the Job
class ProcessPaymentJob < ApplicationJob
queue_as :default
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
discard_on ActiveJob::DeserializationError
def perform(order)
# `order` is an ActiveRecord object — serialized via GlobalID automatically
PaymentGateway.charge(order.amount, order.payment_method)
order.update!(paid_at: Time.current)
end
end
Every job MUST have:
queue_as— explicit queue assignmentretry_onand/ordiscard_on— error handling strategy- Idempotent
perform— safe to run multiple times
Step 3: Enqueue the Job
# Run as soon as a worker is free
ProcessPaymentJob.perform_later(order)
# Run at a specific time
ProcessPaymentJob.set(wait_until: Date.tomorrow.noon).perform_later(order)
# Run after a delay
ProcessPaymentJob.set(wait: 5.minutes).perform_later(order)
# Run with a specific priority (lower = higher priority)
ProcessPaymentJob.set(priority: 0).perform_later(order)
# Run synchronously (useful in console, tests, rake tasks)
ProcessPaymentJob.perform_now(order)
perform_later vs perform_now:
perform_later— enqueues to background queue. Use in controllers, models, services.perform_now— runs inline, blocking. Use in console, rake tasks, tests, and other jobs that need sequential execution.
Step 4: Handle Errors
ALWAYS define error handling. This is non-negotiable.
class ExternalApiJob < ApplicationJob
# Retry transient failures with exponential backoff
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
retry_on Faraday::ConnectionError, wait: 30.seconds, attempts: 3
# Discard permanently broken jobs
discard_on ActiveJob::DeserializationError # Record was deleted
discard_on ArgumentError # Bad arguments, retrying won't help
# Optional: callback when a job is discarded
after_discard do |job, exception|
Rails.logger.warn("Discarded #{job.class.name}: #{exception.message}")
ErrorTracker.notify(exception, job_id: job.job_id)
end
def perform(record)
# ...
end
end
retry_on options:
wait:— seconds or:polynomially_longer(default: 3s)attempts:— max retries (default: 5)queue:— move to a different queue on retrypriority:— change priority on retryjitter:— add randomness to wait time (default: 0.15 = 15%)
discard_on — silently drops the job. Use for unrecoverable errors.
Step 5: Configure Queues and Priority
Queue assignment:
class ImportJob < ApplicationJob
queue_as :low_priority
end
Dynamic queue based on arguments:
class ProcessVideoJob < ApplicationJob
queue_as do
video = self.arguments.first
video.owner.premium? ? :premium : :default
end
end
Priority within a queue (lower number = higher priority):
class UrgentNotificationJob < ApplicationJob
queue_as :notifications
queue_with_priority 0 # Processed first within :notifications queue
end
class DigestNotificationJob < ApplicationJob
queue_as :notifications
queue_with_priority 10 # Processed after urgent ones
end
Solid Queue queue ordering in config/queue.yml:
production:
workers:
- queues: [critical, default, low_priority]
threads: 5
polling_interval: 0.1
⚠️ Queue order takes precedence over priority. All jobs in critical process before ANY job in default, regardless of individual priority values. Priority only matters within a single queue.
Step 6: Serialize Arguments Correctly
Safe argument types (work out of the box):
- Primitives:
String,Integer,Float,Boolean,nil,Symbol - Time types:
Date,Time,DateTime,ActiveSupport::TimeWithZone,Duration - Collections:
Array,Hash(string/symbol keys only),Range - Classes:
Module,Class - ActiveRecord objects — serialized via GlobalID automatically
DANGEROUS — do NOT pass these directly:
# ❌ WRONG — arbitrary Ruby objects are NOT serializable
SomeJob.perform_later(Struct.new(:a).new(1))
SomeJob.perform_later(OpenStruct.new(foo: "bar"))
SomeJob.perform_later(URI.parse("https://example.com"))
SomeJob.perform_later(Money.new(100, "USD")) # Without custom serializer
# ✅ CORRECT — pass primitives, let the job reconstruct
SomeJob.perform_later("https://example.com")
SomeJob.perform_later(100, "USD")
# ✅ CORRECT — ActiveRecord objects use GlobalID
SomeJob.perform_later(user) # Serialized as "gid://app/User/123"
Custom serializer for value objects:
# app/serializers/money_serializer.rb
class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
def serialize(money)
super("amount" => money.amount, "currency" => money.currency)
end
def deserialize(hash)
Money.new(hash["amount"], hash["currency"])
end
def klass
Money
end
end
# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer
Step 7: Use Callbacks
class AuditedJob < ApplicationJob
before_perform :log_start
after_perform :log_completion
around_perform :measure_duration
# before_enqueue / around_enqueue / after_enqueue also available
# after_discard — fires when discard_on matches
private
def log_start
Rails.logger.info("Starting #{self.class.name} #{job_id}")
end
def log_completion
Rails.logger.info("Completed #{self.class.name} #{job_id}")
end
def measure_duration
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
StatsD.measure("jobs.#{self.class.name.underscore}.duration", elapsed)
end
end
Available callbacks: before_enqueue, around_enqueue, after_enqueue, before_perform, around_perform, after_perform, after_discard
Step 8: Test the Job
Test job behavior (does it do the right thing?):
require "test_helper"
class ProcessPaymentJobTest < ActiveSupport::TestCase
test "marks order as paid" do
order = orders(:unpaid_order)
perform_enqueued_jobs do
ProcessPaymentJob.perform_later(order)
end
order.reload
assert order.paid_at.present?
end
test "retries on timeout" do
order = orders(:unpaid_order)
assert_enqueued_with(job: ProcessPaymentJob) do
ProcessPaymentJob.perform_later(order)
end
end
end
Test that jobs get enqueued (from calling code):
class OrdersControllerTest < ActionDispatch::IntegrationTest
test "enqueues payment job on create" do
assert_enqueued_with(job: ProcessPaymentJob) do
post orders_path, params: { order: valid_order_params }
end
end
test "enqueues to correct queue" do
assert_enqueued_with(job: ProcessPaymentJob, queue: "critical") do
post orders_path, params: { order: valid_order_params }
end
end
end
Test helpers:
# Execute all enqueued jobs inline
perform_enqueued_jobs do
SomeJob.perform_later(args)
end
# Execute only specific job types
perform_enqueued_jobs(only: ProcessPaymentJob) do
# ...
end
# Assert a job was enqueued
assert_enqueued_with(job: MyJob, args: [record], queue: "default")
# Assert number of jobs enqueued
assert_enqueued_jobs 2 do
# ...
end
# Assert no jobs enqueued
assert_no_enqueued_jobs do
# ...
end
Step 9: ActionMailer Integration
# Send email asynchronously — enqueues via Active Job
UserMailer.welcome(@user).deliver_later
# Send with delay
UserMailer.welcome(@user).deliver_later(wait: 1.hour)
# Send immediately (bypasses queue)
UserMailer.welcome(@user).deliver_now
⚠️ In Rake tasks, always use deliver_now — the process may exit before deliver_later jobs are processed.
Error reporting for mailer jobs:
class ApplicationMailer < ActionMailer::Base
ActionMailer::MailDeliveryJob.rescue_from(Exception) do |exception|
Rails.error.report(exception)
raise exception
end
end
Step 10: Bulk Enqueuing
# Create job instances (don't call perform_later individually)
jobs = users.map { |user| WelcomeEmailJob.new(user) }
# Enqueue all at once — single round-trip to the queue backend
ActiveJob.perform_all_later(jobs)
# With options
jobs = users.map { |user| WelcomeEmailJob.new(user).set(wait: 1.day) }
ActiveJob.perform_all_later(jobs)
# Mix different job classes
jobs = [
CleanupJob.new(record),
NotifyJob.new(user),
ExportJob.new(data)
]
ActiveJob.perform_all_later(jobs)
⚠️ around_enqueue callbacks are NOT triggered during bulk enqueuing. Use ActiveSupport::Notifications with the enqueue_all.active_job event if you need to hook into bulk operations.
Solid Queue Configuration
See references/queues-and-priority.md for full multi-worker queue.yml examples.
Starting the Queue Worker
bin/jobs start # Development — run in separate terminal
Concurrency Controls
class ImportJob < ApplicationJob
# Only 2 imports per account at a time
limits_concurrency to: 2, key: ->(account) { account }, duration: 5.minutes
def perform(account)
account.run_import
end
end
Recurring Tasks (config/recurring.yml)
production:
cleanup_old_records:
class: CleanupJob
schedule: every day at 3am
send_daily_digest:
class: DailyDigestJob
args: [summary]
schedule: every day at 9am
health_check:
command: "HealthCheck.run"
schedule: every 5 minutes
Job Continuations (Rails 8.1+)
For long-running jobs that may be interrupted:
class LargeImportJob < ApplicationJob
include ActiveJob::Continuable
def perform(import_id)
@import = Import.find(import_id)
step :validate do
@import.validate_data!
end
step :process do |step|
@import.rows.find_each(start: step.cursor) do |row|
row.process!
step.advance! from: row.id
end
end
step :finalize do
@import.mark_complete!
end
end
end
Common Agent Mistakes
- Passing non-serializable objects — Only primitives, ActiveRecord (via GlobalID), and registered serializer types. Everything else will raise
SerializationError. - No error handling — Every job needs
retry_onordiscard_on. A job without error handling is a ticking time bomb. - Not handling
DeserializationError— If a record is deleted between enqueue and perform, you get this error. Alwaysdiscard_on ActiveJob::DeserializationError. - Blocking jobs without timeouts — HTTP calls, external APIs must have timeouts. Add
retry_onfor timeout errors. - Non-idempotent jobs — If the job charges money, send a guard:
return if order.paid?. Jobs CAN run twice. - Using
perform_nowin production controllers — This blocks the request. Useperform_later. - Forgetting queue workers in dev —
bin/jobs startor configureconfig.active_job.queue_adapter = :asyncfor development. - Testing with
perform_nowwhen you mean to test enqueuing — Useassert_enqueued_withto verify the job was enqueued,perform_enqueued_jobsto verify behavior.
ApplicationJob Base Class
Set up sensible defaults in app/jobs/application_job.rb:
class ApplicationJob < ActiveJob::Base
# Automatically retry transient failures
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
# Discard if the record no longer exists
discard_on ActiveJob::DeserializationError
# Report errors to your error tracker
rescue_from(Exception) do |exception|
Rails.error.report(exception)
raise exception
end
# Wait for transaction commit before enqueuing (prevents race conditions)
self.enqueue_after_transaction_commit = true
end
Running and Debugging
# Start queue worker
bin/jobs start
# Run a job from console
ProcessPaymentJob.perform_now(Order.last)
# Check queue status (with mission_control-jobs gem)
# Visit /jobs in browser
# Enable verbose enqueue logging
# config/environments/development.rb
config.active_job.verbose_enqueue_logs = true
Reference Files
See the references/ directory for detailed patterns and edge cases:
references/serialization.md— Job lifecycle, GlobalID, argument types, custom serializersreferences/error-handling.md— retry_on, discard_on, idempotency guardsreferences/queues-and-priority.md— Multi-worker setup, naming, priority, ActionMailer integrationreferences/testing.md— Enqueue assertions, job behavior, error handling, scheduled job testsreferences/solid-queue.md— Database setup, concurrency controls, recurring tasks, monitoringreferences/advanced-patterns.md— Continuations, performance, anti-patterns, migration guides