Rails 7.2 adds enqueue_after_transaction_commit to prevent job race conditions

· 5 min read

Scheduling background jobs inside database transactions is a common anti-pattern which is a source of several production bugs in Rails applications. The job can execute before the transaction commits, leading to RecordNotFound or ActiveJob::DeserializationError because the data it needs does not exist yet. Or worse, the job could run assuming the txn would commit, but it rolls back at a later stage. We don’t need that kind of optimism.

Rails 7.2 addresses this with enqueue_after_transaction_commit, which automatically defers job enqueueing until the transaction completes.

Before

Consider a typical pattern where you create a user and send a welcome email:

class UsersController < ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)
      WelcomeEmailJob.perform_later(@user)
    end
  end
end

This code works fine in development where your job queue is slow and transactions commit quickly. In production, with a fast Redis-backed queue like Sidekiq and a busy database, the job can start executing before the transaction commits:

Timeline:
1. Transaction begins
2. User INSERT executes (not committed yet)
3. Job enqueued to Redis
4. Sidekiq picks up job immediately
5. Job tries to find User -> RecordNotFound!
6. Transaction commits (too late)

The same problem occurs with after_create callbacks in models:

class Project < ApplicationRecord
  after_create -> { NotifyParticipantsJob.perform_later(self) }
end

The Workaround

The standard fix was to use after_commit callbacks instead:

class Project < ApplicationRecord
  after_create_commit -> { NotifyParticipantsJob.perform_later(self) }
end

Or wrap job scheduling in explicit after_commit blocks:

class UsersController < ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)

      ActiveRecord::Base.connection.after_transaction_commit do
        WelcomeEmailJob.perform_later(@user)
      end
    end
  end
end

This worked but had problems:

  • Easy to forget: Using after_create instead of after_create_commit is a common mistake
  • Scattered logic: Job scheduling gets coupled to model callbacks instead of staying in controllers or service objects
  • Verbose: Wrapping every perform_later call in after_commit blocks adds boilerplate
  • Testing friction: Transaction callbacks behave differently in test environments using database cleaner with transactions

The after_commit_everywhere gem became popular specifically to address this problem. It lets you use after_commit callbacks anywhere in your application, not just in ActiveRecord models:

class UserRegistrationService
  include AfterCommitEverywhere

  def call(params)
    User.transaction do
      user = User.create!(params)

      after_commit do
        WelcomeEmailJob.perform_later(user)
      end
    end
  end
end

The gem hooks into ActiveRecord’s transaction lifecycle and ensures callbacks only fire after the outermost transaction commits. It handled nested transactions correctly and became a go-to solution for service objects that needed transaction-safe job scheduling.

Some teams built their own lightweight wrappers instead:

# Custom AsyncRecord class that hooks into transaction callbacks
class AsyncRecord
  def initialize(&block)
    @callback = block
  end

  def has_transactional_callbacks?
    true
  end

  def committed!(*)
    @callback.call
  end

  def rolledback!(*)
    # Do nothing if transaction rolled back
  end
end

# Usage
User.transaction do
  user = User.create!(params)
  record = AsyncRecord.new { WelcomeEmailJob.perform_later(user) }
  user.class.connection.add_transaction_record(record)
end

Both approaches worked, but required teams to remember to use them consistently.

Rails 7.2

Rails 7.2 makes Active Job transaction-aware. Jobs are automatically deferred until the transaction commits, and dropped if it rolls back.

Enable it globally in your application:

# config/application.rb
config.active_job.enqueue_after_transaction_commit = :default

Now the original code just works:

class UsersController < ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)
      WelcomeEmailJob.perform_later(@user)  # Deferred until commit
    end
  end
end

The job only gets enqueued after the transaction successfully commits. If the transaction rolls back, the job is never enqueued.

Configuration Options

You can control this behavior at three levels:

Global configuration:

# config/application.rb
config.active_job.enqueue_after_transaction_commit = :default

Per-job configuration:

class WelcomeEmailJob < ApplicationJob
  self.enqueue_after_transaction_commit = :always
end

class AuditLogJob < ApplicationJob
  self.enqueue_after_transaction_commit = :never  # Queue immediately
end

The available values are:

  • :default - Let the queue adapter decide the behavior
  • :always - Always defer until transaction commits
  • :never - Queue immediately (pre-7.2 behavior)

Checking Enqueue Status

Since perform_later returns immediately even when the job is deferred, you can check if it was actually enqueued:

User.transaction do
  user = User.create!(user_params)
  job = WelcomeEmailJob.perform_later(user)

  # job.successfully_enqueued? returns false here (still deferred)
end

# After transaction commits, job.successfully_enqueued? returns true

Model Callbacks Simplified

You can now safely use after_create for job scheduling without worrying about transaction timing:

class Project < ApplicationRecord
  # This is now safe with enqueue_after_transaction_commit enabled
  after_create -> { NotifyParticipantsJob.perform_later(self) }
end

The job automatically waits for any enclosing transaction to complete.

When to Disable

Some scenarios require immediate enqueueing:

  • Database-backed queues: If you use Solid Queue, GoodJob, or Delayed Job with the same database, jobs are part of the same transaction and this deferral is unnecessary
  • Fire-and-forget jobs: Jobs that do not depend on the transaction data can run immediately
  • Time-sensitive operations: If you need the job queued at a specific moment regardless of transaction state
class TimeStampedJob < ApplicationJob
  self.enqueue_after_transaction_commit = :never

  def perform
    # This job needs to capture the exact enqueue time
  end
end

Conclusion

enqueue_after_transaction_commit eliminates a common source of race conditions in Rails applications. Instead of remembering to use after_commit callbacks or building custom workarounds, jobs are automatically deferred until transactions complete.

References

Prateek Choudhary
Prateek Choudhary
Technology Leader