- Home
- Blog
- Ruby & Rails Core
- Rails 8.2 Callbacks and Background Jobs in Production
Rails 8.2 Callbacks and Background Jobs in Production
How Callback Misuse Turns Into Job Backlogs, Latency Spikes, and Silent Data Loss
You add after_commit :sync_to_crm to your model. It works in development. In production, with 50 concurrent requests, it starts dropping CRM syncs silently. No errors. No logs. Just missing data.
Rails callbacks and background jobs interact in ways that bite you in production, not in tests. This post covers the specific failure modes, why they happen, and how to fix them in Rails 8.1+ with Ruby 4.0.
The Core Problem: Callbacks Don’t Know About Job Infrastructure
Rails callbacks run inside the request cycle, inside transactions, or on object lifecycle events — all synchronous. Background job adapters (Sidekiq, Solid Queue, GoodJob) are asynchronous. These two systems don’t share a lifecycle, and that mismatch is where bugs hide.
The most common assumption developers make:
“I enqueue in
after_save, so the job always gets the latest data.”
That’s wrong in three different ways.
When after_save Breaks Job Enqueuing
after_save fires inside the transaction. If you enqueue a job here and the transaction rolls back, the job still runs. The record doesn’t exist or has stale state.
# Dangerous: job enqueued before transaction commits
class Order < ApplicationRecord
after_save :enqueue_invoice_job
private
def enqueue_invoice_job
InvoiceGeneratorJob.perform_later(id)
end
end
-- Timeline that kills you:
-- 1. Transaction starts
-- 2. order saved, after_save fires, job enqueued to Sidekiq
-- 3. Something else in the transaction fails
-- 4. ROLLBACK issued
-- 5. Job picks up order.id — record is gone or never committed
The job worker hits Order.find(id) and gets ActiveRecord::RecordNotFound. If you rescue that silently (common in job error handlers), you lose the invoice permanently.
Fix: use after_commit instead.
class Order < ApplicationRecord
# Fires only after the database transaction commits
after_commit :enqueue_invoice_job, on: [:create, :update]
private
def enqueue_invoice_job
InvoiceGeneratorJob.perform_later(id)
end
end
after_commit guarantees the record exists in the database before the job runs. This is the correct default for any job enqueuing from a model callback.
The after_commit Race Condition You’re Not Testing
after_commit fixes the transaction problem. It introduces a different one: the job worker reads the record before your after_commit callback finishes propagating across replicas.
If you use read replicas (common in production Rails with PostgreSQL), replication lag means the job reads stale data or hits a replica that hasn’t received the commit yet.
class User < ApplicationRecord
after_commit :sync_to_search_index
private
def sync_to_search_index
# This job may run on a worker that reads from a replica
# Replica may not have the update yet
UserSearchIndexJob.perform_later(id)
end
end
The fix is to pass the data you need directly to the job instead of re-fetching it:
after_commit do
UserSearchIndexJob.perform_later(id, name, email, updated_at.to_s)
end
Or add a short delay to let replication catch up (pragmatic, not elegant):
after_commit do
UserSearchIndexJob.set(wait: 2.seconds).perform_later(id)
end
Neither is perfect. Passing data avoids the replica read. Adding a delay is a band-aid. Choose based on whether your job needs the full record or just specific fields.
Callback Chains That Multiply Jobs
Callbacks compose. That’s the problem.
class User < ApplicationRecord
after_commit :notify_crm
after_commit :update_billing
after_commit :sync_permissions
# Three jobs per save, always, regardless of what changed
end
Every save — even touch, even updating last_seen_at — triggers all three jobs. At 1,000 saves per minute, that’s 3,000 job enqueues per minute for work that often doesn’t need to happen.
Rails 8.1 added after_commit callback support with if: conditions that use saved_change_to_*? helpers. Use them:
class User < ApplicationRecord
after_commit :notify_crm, if: :saved_change_to_email?
after_commit :update_billing, if: :saved_change_to_plan?
after_commit :sync_permissions, if: :saved_change_to_role?
end
saved_change_to_email? returns true only when the email column changed in the committed transaction. This is available in Rails 5.1+ and works correctly inside after_commit.
If a callback fires for every save when it should fire for specific changes, you’re paying the job queue cost of a fire-hose on a drip.
Hidden Trap: Callbacks in Bulk Operations
update_all, delete_all, insert_all — none of them fire callbacks. This is documented, but it breaks assumptions when your business logic lives in callbacks.
# No callbacks. No jobs. No CRM syncs. No audit logs.
User.where(plan: "trial").update_all(active: false)
If your after_commit triggers billing logic, audit logging, or notifications, skipping callbacks here creates silent data inconsistency.
The safe pattern for bulk operations with side effects:
# Option 1: iterate in batches (slow but correct)
User.where(plan: "trial").find_each do |user|
user.update!(active: false) # triggers callbacks
end
# Option 2: bulk update, then enqueue a cleanup job
User.where(plan: "trial").update_all(active: false)
BulkDeactivationCleanupJob.perform_later(plan: "trial")
Option 2 is better for large datasets. The job handles the side effects in the background with proper error handling and retries.
Comparison: Callback Hooks and Job Safety
| Hook | Fires After Commit | Safe for Job Enqueuing | Works in Bulk Ops |
|---|---|---|---|
after_save |
No | No (transaction may rollback) | No |
after_create |
No | No | No |
after_update |
No | No | No |
after_commit |
Yes | Yes | No |
after_destroy_commit |
Yes | Yes | No |
| None (explicit call) | Depends on where called | Yes | Yes |
The Case for Removing Callbacks Entirely
For complex job orchestration, callbacks are the wrong tool. They’re invisible, hard to test in isolation, and they scatter business logic across model files.
The alternative: explicit service objects that own both the persistence and the job enqueuing.
# app/services/orders/creator.rb
module Orders
class Creator
def initialize(order_params)
@order_params = order_params
end
def call
order = nil
ActiveRecord::Base.transaction do
order = Order.create!(@order_params)
end
# Explicit, readable, testable
InvoiceGeneratorJob.perform_later(order.id)
CrmSyncJob.perform_later(order.id)
order
end
end
end
What you gain:
- You see exactly what jobs fire and when
- You test the service, not model lifecycle hooks
- You control job ordering
- You avoid hidden side effects when the model is used elsewhere
What you lose:
- Nothing you actually need
Decision Flowchart: Callbacks vs Explicit Enqueuing
Do you need to enqueue a background job when a record changes?
├── Is it always needed on every save?
│ ├── Yes → after_commit with saved_change_to_*? guard
│ └── No → explicit call in controller or service
│
├── Is this a bulk operation (update_all / insert_all)?
│ └── Always → explicit job after bulk op, no callbacks
│
├── Does the job depend on the committed state of the record?
│ └── Yes → after_commit only, never after_save
│
└── Is the logic complex or shared across multiple flows?
└── Yes → service object with explicit perform_later
Practical Production Pattern
Here’s the pattern for a Rails 8.1+ app using Solid Queue or Sidekiq:
class Order < ApplicationRecord
# Guard conditions prevent unnecessary job enqueuing
after_commit :enqueue_fulfillment_job,
on: :create
after_commit :enqueue_billing_update_job,
on: :update,
if: :saved_change_to_total_amount?
private
def enqueue_fulfillment_job
# Pass fields instead of relying on re-fetch from replica
FulfillmentJob.perform_later(
id,
line_items.pluck(:id),
total_amount.to_s
)
end
def enqueue_billing_update_job
BillingUpdateJob.perform_later(id, total_amount.to_s)
end
end
For jobs that need the full record and you’re on a replica setup, add a jitter delay:
FulfillmentJob.set(wait: 1.second).perform_later(id)
For large-scale bulk operations:
# In a rake task or admin action
Order.where(status: "pending", created_at: ..1.month.ago).in_batches(of: 500) do |batch|
batch.update_all(status: "expired")
# Explicit cleanup, not callbacks
OrderExpirationNotifierJob.perform_later(batch.pluck(:id))
end
Final Thoughts
after_commit is the correct hook for job enqueuing — after_save is not. Guards with saved_change_to_*? prevent callback inflation at scale. Bulk operations bypass all callbacks, so side effects need explicit handling. For complex orchestration, a service object with explicit perform_later calls is more maintainable than scattered model callbacks.
The issue isn’t that callbacks are wrong. It’s that invisible code with side effects is hard to reason about under production load. Make it visible.
Sources
Was this article helpful?
Your feedback helps us improve our content
How We Verify Conversions
Every conversion shown on this site follows a strict verification process to ensure correctness:
- Compare results on same dataset — We run both SQL and ActiveRecord against identical test data and verify results match
-
Check generated SQL with
to_sql— We inspect the actual SQL Rails generates to catch semantic differences (INNER vs LEFT JOIN, WHERE vs ON, etc.) - Add regression tests for tricky cases — Edge cases like NOT EXISTS, anti-joins, and predicate placement are tested with multiple scenarios
- Tested on Rails 8.1.1 — All conversions verified on current Rails version to ensure compatibility
Last updated: May 27, 2026
Try These Queries in Our Converter
See the SQL examples from this article converted to ActiveRecord—and compare the SQL Rails actually generates.
Deep Dive into ActiveRecord
Raza Hussain
Full-stack developer specializing in Ruby on Rails, React, and modern JavaScript. 15+ years upgrading and maintaining production Rails apps. Led Rails 4/5 → 7 upgrades with 40% performance gains, migrated apps from Heroku to Render cutting costs by 35%, and built systems for StatusGator, CryptoZombies, and others. Available for Rails upgrades, performance work, and cloud migrations.
More on Ruby & Rails Core
destroy_all vs delete_all in Rails: Performance, Callbacks, and When to Use Each
destroy_all runs callbacks, one query per record. delete_all = one SQL DELETE, no callbacks. Learn when each is safe and what breaks if you choose wrong.
Added select() to Limit Columns. Performance Improved 40%. Stopped Loading Data I Didn't Need.
Learn how ActiveRecord select() cut query time 40% by limiting columns. Real benchmarks: 18.7MB to 2.1MB data transfer, 72% faster object creation on 14K
ActiveRecord Ran 47 Identical Queries—Bullet Gem Found the Pattern
Discover how Bullet gem caught 47 duplicate queries slowing dashboard to 3.8s. Fix Rails query duplication with instance variables and monitoring tools.
Leave a Response
Responses (0)
No responses yet
Be the first to share your thoughts