enqueue_after_transaction_commit: How Rails 8.2 Fixes an ActiveJob Bug
Rails 8.2 changes how ActiveJob interacts with database transactions, fixing a subtle bug that has bitten many developers. Jobs enqueued inside a transaction now wait until the transaction commits before being dispatched.
The Problem
Consider this common pattern:
class OrdersController < ApplicationController
def create
ActiveRecord::Base.transaction do
@order = Order.create!(order_params)
OrderConfirmationJob.perform_later(@order)
end
end
end
Looks reasonable, right? But there’s a race condition. The job might execute before the transaction commits. When the job runs:
class OrderConfirmationJob < ApplicationJob
def perform(order)
# This might fail! The order might not exist yet in the database
OrderMailer.confirmation(order).deliver_now
end
end
Even worse, if the transaction rolls back, the job still runs against a record that no longer exists.
What Changed
In Rails 8.2, config.active_job.enqueue_after_transaction_commit defaults to true. Jobs enqueued inside a transaction are now held until after the transaction commits.
ActiveRecord::Base.transaction do
@order = Order.create!(order_params)
OrderConfirmationJob.perform_later(@order) # Job is held, not enqueued yet
end
# Transaction commits here
# NOW the job is dispatched to the queue
If the transaction rolls back, the job is never enqueued.
Why This Matters
This fixes several common issues:
- Job fails with RecordNotFound: The job runs before the record is visible to other database connections
- Job processes stale data: The job reads uncommitted data that gets rolled back
- Duplicate work: Transaction retries cause multiple jobs to be enqueued
Even with this fix, jobs can still fail for other reasons. Pair this with smart retry strategies using error-aware delays to handle transient failures gracefully.
How to Enable It
New Rails 8.2 apps
It’s enabled by default. Nothing to do.
Upgrading to Rails 8.2
Add to config/initializers/new_framework_defaults_8_2.rb:
Rails.application.config.active_job.enqueue_after_transaction_commit = true
Or set it in config/application.rb:
config.active_job.enqueue_after_transaction_commit = true
Per-job override
If a specific job should be enqueued immediately (perhaps it doesn’t depend on the transaction):
class NotificationJob < ApplicationJob
self.enqueue_after_transaction_commit = false
def perform(message)
# This job will be enqueued immediately, even inside a transaction
end
end
Things to Watch Out For
Jobs outside transactions
Jobs enqueued outside of a transaction are dispatched immediately, as before:
# No transaction, job enqueues immediately
SomeJob.perform_later(data)
Nested transactions
The job waits for the outermost transaction to commit:
ActiveRecord::Base.transaction do
User.create!(...)
ActiveRecord::Base.transaction do
Order.create!(...)
OrderJob.perform_later(@order) # Held until outer transaction commits
end
end
# Job dispatched here
Return value timing
perform_later still returns the job instance immediately, even though the actual enqueue is deferred:
ActiveRecord::Base.transaction do
job = MyJob.perform_later(data)
job.successfully_enqueued? # true (optimistically)
end
If the actual enqueue fails later, the job’s status is updated, but this happens after the transaction.
Wrapping Up
This is a sensible default that prevents a common class of bugs. If you’ve been manually using after_commit callbacks to enqueue jobs, you can now simplify your code.
For long-running jobs like CSV imports, consider combining this with ActiveJob::Continuable for resumable background work so your jobs survive both transaction issues and worker restarts.
For existing apps, test thoroughly before enabling, as the timing change might affect job ordering assumptions.