Skip to main content

Rails 8.2 Callbacks and Background Jobs in Production

How Callback Misuse Turns Into Job Backlogs, Latency Spikes, and Silent Data Loss

A
Raza Hussain
· Updated: · 8 min read · 206
Rails 8.2 Callbacks and Background Jobs in Production

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

Be the first to vote!

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.

206

Leave a Response

Responses (0)

No responses yet

Be the first to share your thoughts

R

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.

💼 15 years experience 📝 48 posts