Background Jobs

Ruby on Rails Intermediate/Advanced — Background Jobs
Ruby on Rails Intermediate/Advanced
Course 2 · Chapter 4 · Background Jobs

🛠️ Background Jobs

Last chapter closed with a promise: work that shouldn't block the request/response cycle. Sending a confirmation email, resizing an uploaded image, calling a slow third-party API — none of these need to finish before the user gets a response. Making them wait anyway means slower page loads at best, and a request that times out entirely if the slow thing takes too long.

🐢 The Problem With Doing Everything Inline

# app/controllers/posts_controller.rb -- don't do this def create @post = Post.create!(post_params) NotifierMailer.new_post_email(@post).deliver_now # blocks until the email actually sends redirect_to @post end

If the mail server is slow, or down, the user sitting on the other end of that create request waits for it too — or the request times out and the post never even gets its redirect, even though it saved successfully. The fix is the same shape as the previous three chapters: pull the risky, slow, or optional part out into its own well-defined unit, instead of leaving it tangled into the request cycle.

📮 Active Job — Rails' Abstraction Layer

Active Job is Rails' framework-provided abstraction for background work, the same role Active Record plays for databases: you write job classes against one consistent API, and a swappable adapter underneath actually handles running them — Sidekiq, Resque, Delayed Job, or Rails' built-in Async adapter for development. Switching adapters in production is a one-line config change, not a rewrite of every job.

# app/jobs/new_post_notifier_job.rb class NewPostNotifierJob < ApplicationJob queue_as :default def perform(post_id) post = Post.find(post_id) NotifierMailer.new_post_email(post).deliver_now end end
⚠ Pass IDs, Not Objects, Into Jobs

perform(post_id) takes an integer, not the Post object itself. Jobs are serialized (to JSON, typically) and stored until a worker picks them up — by the time that happens, the in-memory Post object from the controller is long gone, and the record may have changed or even been deleted. Looking it up fresh inside perform guarantees the job always works with current data, and fails cleanly (ActiveRecord::RecordNotFound) if the record is gone rather than silently acting on stale data.

🚀 Enqueuing a Job

# app/controllers/posts_controller.rb def create @post = Post.create!(post_params) NewPostNotifierJob.perform_later(@post.id) # enqueues and returns immediately redirect_to @post end

perform_later hands the job off to the queue and returns instantly — the controller action finishes and the user gets their redirect right away, while the actual email sends whenever a worker gets to it. perform_now (the same method every job class gets) runs the job's perform immediately and synchronously, useful for testing or for the rare case where you genuinely do need to wait for the result.

🔴 Setting Up Sidekiq

$ bundle add sidekiq
# config/application.rb config.active_job.queue_adapter = :sidekiq # config/routes.rb -- optional web dashboard require "sidekiq/web" mount Sidekiq::Web => "/sidekiq"

Sidekiq is the most widely used Active Job adapter in production Rails apps. It's backed by Redis rather than the primary database — jobs are pushed onto Redis-backed queues, and a separate sidekiq worker process (started independently of the Rails server itself, with bundle exec sidekiq) pulls jobs off those queues and runs them, using threads to process several jobs concurrently per process. The optional Sidekiq::Web mount gives you a live dashboard of queued, running, retrying, and failed jobs.

🔁 Retries and Failure

class NewPostNotifierJob < ApplicationJob queue_as :default retry_on Net::SMTPServerBusy, wait: :exponentially_longer, attempts: 5 discard_on ActiveRecord::RecordNotFound def perform(post_id) post = Post.find(post_id) NotifierMailer.new_post_email(post).deliver_now end end

retry_on declares that a specific error is transient and worth trying again — a busy mail server probably isn't busy forever, so retrying with exponentially increasing delays gives it time to recover. discard_on is the opposite judgment call: if the post was deleted before the job ran, retrying RecordNotFound five more times can't fix anything, so the job gives up immediately rather than clogging the queue with retries that will never succeed.

🧪 Testing a Job

Chapter 3 built request and model specs; jobs get their own small addition on top of the same RSpec setup:

# spec/jobs/new_post_notifier_job_spec.rb RSpec.describe NewPostNotifierJob, type: :job do it "enqueues the job" do post = create(:post) expect { NewPostNotifierJob.perform_later(post.id) } .to have_enqueued_job.with(post.id) end it "sends the email when performed" do post = create(:post) expect { NewPostNotifierJob.perform_now(post.id) } .to change { ActionMailer::Base.deliveries.count }.by(1) end end

The first example checks that enqueuing happens at all, without actually running the job's code — fast, and enough to confirm the controller wires things up correctly. The second uses perform_now to run the job's logic directly and confirm its actual effect. Splitting "was it enqueued" from "does it work when it runs" mirrors the same model-spec-vs-request-spec split from Chapter 3: narrow tests for one piece of behavior, not one giant test trying to prove everything at once.

📜 Background Jobs, Side by Side

Active Job + Sidekiq (Rails) vs BullMQ (Node, node3-6)

ConcernNode + BullMQRails + Active Job/Sidekiq
Backing storeRedisRedis (via Sidekiq specifically)
Enqueuingqueue.add("job-name", data)SomeJob.perform_later(args)
Job definitionA worker function processing named jobsOne class per job, perform method
RetriesConfigured per queue/job optionsretry_on / discard_on, per error class
Swapping backendsTied to BullMQ/Redis directlyActive Job is adapter-agnostic; jobs unchanged

The underlying idea — a durable, Redis-backed queue and one or more worker processes pulling jobs off it — is the same one node3-6 already covered for message queues in general. Active Job's contribution is the same one Active Record makes for databases: one API on top, so the app's job classes don't have to know or care whether Sidekiq, Resque, or something else entirely is doing the actual work underneath.

💻 Coding Challenges

Challenge 1: A Basic Job

Write a ResizeImageJob that takes an image_id, looks up the corresponding Image record, and calls a (hypothetical) image.resize! method. Then write the one line a controller's create action would use to enqueue it after saving a new image.

Goal: Practice the perform_later pattern with an ID, not an object, passed in.

→ Solution

Challenge 2: Retry and Discard

Add retry_on for a hypothetical ImageProcessingTimeout error (retry up to 3 times, with exponentially increasing delays) and discard_on for ActiveRecord::RecordNotFound to the ResizeImageJob from Challenge 1.

Goal: Practice distinguishing a worth-retrying failure from a give-up-immediately failure.

→ Solution

Challenge 3: Testing the Job

Write two RSpec examples for ResizeImageJob: one confirming that calling perform_later enqueues the job with the correct image_id, and one confirming that perform_now actually calls resize! on the image.

Goal: Practice the enqueue-vs-actually-runs testing split from this chapter.

→ Solution

💎 Not Everything Belongs in a Job

Background jobs trade immediacy for resilience: the user stops waiting, but they also stop knowing the outcome in real time. That tradeoff is right for a confirmation email, wrong for "did my payment go through" — anything the user needs to see the result of before the page finishes loading should usually stay inline, or use a job plus a way to poll/notify the user once it completes.

🎯 What's Next

Next chapter: APIs with Rails — API-only mode, JSON serialization, and how Rails' REST conventions carry over when the "view" is JSON instead of ERB, contrasted with Express's REST API design chapter.