How to Prevent Race Conditions in Ruby on Rails: Complete Guide

Race Conditions in Rails: Avoiding Data Conflicts

🚨 What is a Race Condition in Rails?

A race condition happens when two or more operations try to read and write the same data at the same time β€” and the final result depends on which operation finishes first.

This usually occurs in web applications when multiple users or background jobs perform actions on the same database record at the same time without proper locking or safety measures.

Because Rails apps run in multi-threaded or multi-process environments (especially with Sidekiq, Puma, or Unicorn), two actions might conflict and cause data corruption or inconsistent behavior.

🧠 Simple Example:

Let’s say a user has $100 in their wallet. Two different processes try to deduct $60 at the same time:

  1. Both processes read the balance: $100
  2. Both calculate new balance: $100 – $60 = $40
  3. Both save $40 β†’ Final balance is $40 (instead of $100 – $60 – $60 = βˆ’$20)

This results in the app thinking there’s still money left β€” but it’s actually wrong. That’s a race condition!

πŸ§ͺ When Do Race Conditions Happen in Rails?

  • Two users placing an order for the last item at the same time
  • Two background jobs updating the same record
  • Two API calls modifying shared data (e.g., likes, inventory, counters)

These bugs are usually intermittent and hard to detect β€” that’s why it’s so important to write safe, race-free code using the tools Rails provides.

πŸ“˜ Key Terms & Concepts

TermDescription
Race ConditionOccurs when two or more processes access the same data at the same time, causing unpredictable outcomes.
TransactionA group of database operations that either all succeed or all fail, ensuring consistency.
with_lockA Rails method that locks a database row so no other operation can modify it during execution.
Optimistic LockingAssumes multiple updates are rare. Uses a version column (lock_version) to detect conflicts before saving.
StaleObjectErrorError raised when an outdated record is saved and lock_version doesn’t match the current version.
ConcurrencyWhen two or more processes run at the same time, possibly interacting with the same data.
DeadlockA situation where two processes wait for each other indefinitely, locking each other out.
Row-Level LockingLocks a specific row in the database during an update to prevent concurrent changes.
Redis LockingUses Redis (e.g., Redlock) to coordinate locks in distributed systems to prevent race conditions.
Thread SafetyEnsures code works correctly when accessed by multiple threads at the same time.

🧩 Types of Race Conditions & How to Avoid Them

TypeWhat HappensHow to Avoid
Read-Modify-WriteTwo processes read the same value and modify it based on the stale data.Use with_lock or wrap logic in a DB transaction to lock the row during update.
Lost UpdateOne update is overwritten by another, losing the first update entirely.Use Optimistic Locking with lock_version.
Double ExecutionTwo workers/processes perform the same action (e.g., send payment or email) twice.Use idempotent logic with unique keys or state-checks.
DeadlockTwo processes wait for each other to release a lock, leading to an indefinite wait.Keep transactions short and access tables in the same order to prevent circular waits.
Check-Then-ActApp checks a condition and acts on it β€” but the condition changes before the action is performed.Wrap both check and action in a transaction or use with_lock.
Background Job ConflictMultiple Sidekiq jobs working on the same record at once.Use Redis locks or with_lock in the job to serialize access.
Counter CorruptionIncrementing/decrementing counters from multiple threads corrupts the value.Use update_counters method which handles concurrency safely in Rails.
Inventory OversellingTwo users buy the last product at the same time.Use Product.with_lock when reducing stock to prevent overselling.
Job Race on StateTwo jobs or requests change the state (e.g., from ‘draft’ to ‘published’) without knowing the other did it.Use state machine gems (like aasm) or locks to ensure only one state transition at a time.
Token DuplicationMultiple processes generate the same token/key for different users.Use SecureRandom.uuid and validates_uniqueness_of with DB constraints.

πŸ“¦ Gems & Libraries to Prevent Race Conditions

Gem / LibraryPurposeExample Usage
with_lock (built-in)Row-level locking using ActiveRecord. Prevents concurrent updates to the same record. user.with_lock { user.balance -= 100; user.save! }
lock_version (built-in)Enables optimistic locking by adding a version counter to the table. Add a column lock_version:integer and use save!
redlockImplements distributed locking using Redis β€” good for Sidekiq or multi-server coordination. Redlock::Client.new([\"redis://localhost:6379\"]).lock(\"resource\", 2000)
aasmState machine gem for safely transitioning between states (e.g., from draft β†’ published). aasm do state :draft, :published; event :publish do ... end end
sidekiq-unique-jobsPrevents the same Sidekiq job from being enqueued or run multiple times concurrently. sidekiq_options unique: :until_executed
Advisory Locking (PostgreSQL)Use Postgres’s advisory locks via SQL or gems to create custom locks outside rows. ActiveRecord::Base.connection.execute(\"SELECT pg_advisory_lock(1234)\")

βœ… Solutions to Race Conditions

SolutionDescriptionWhen to Use
Database TransactionsEnsures multiple DB operations are completed as a single unit, or rolled back together.When multiple queries need to run together without interference.
with_lockLocks a database row until a block of code completes, preventing concurrent writes.When updating a single record that could be accessed simultaneously.
Optimistic LockingUses a version column (lock_version) to detect and prevent stale updates.When conflicts are rare but data integrity must be preserved.
Redis Distributed LocksLocks resources across multiple workers or servers using Redis (e.g., Redlock).When running background jobs or services in distributed environments.
Idempotent OperationsDesigns actions so that even if repeated, the outcome stays the same.For payment processing, email sending, or inventory updates.
Database ConstraintsUses unique indexes or constraints to enforce consistency at the DB level.When tokens, emails, or keys must be unique.
State MachinesControls state transitions to avoid race conditions during updates (e.g., aasm gem).When an object must move between predefined states safely (e.g., draft β†’ published).
Job UniquenessPrevents duplicate jobs from executing at the same time using job deduplication gems.When the same background task could be triggered by multiple users or processes.
Retry & Rescue MechanismsRetries operations when temporary race conditions or lock failures occur.When using optimistic locking or handling external APIs with occasional failure.
Versioned APIs & QueuingQueues actions and processes them sequentially to avoid direct concurrent access.When requests need to be processed in order, like purchases or tickets.

πŸ§ͺ Best Implementation: Avoiding Race Condition with Database Transactions

Let’s walk through a real-world example where we use a database transaction to prevent race conditions when deducting balance from a user’s wallet.

πŸ’‘ Scenario:

Multiple users are trying to purchase an item at the same time. Each purchase deducts balance from the user’s account and reduces inventory.

🚫 Without Transaction (Buggy)

user = User.find(1) product = Product.find(1) if user.balance >= product.price user.balance -= product.price product.stock -= 1 user.save! product.save! end

Problem: If two requests hit this at the same time, both may pass the balance/stock check and update the same record incorrectly. This causes data loss or overselling.

βœ… With Transaction (Correct)

ActiveRecord::Base.transaction do user = User.lock.find(1) # lock the row product = Product.lock.find(1) # lock the row raise \”Insufficient balance\” if user.balance < product.price raise \"Out of stock\" if product.stock <= 0 user.balance -= product.price product.stock -= 1 user.save! product.save! end

🧠 Why It Works:

  • transaction ensures either everything happens or nothing does.
  • .lock forces the DB to prevent any other process from modifying the same rows until the transaction finishes.
  • All changes happen in one atomic unit, so no two processes can interfere mid-update.

πŸ“Œ Tip:

Always combine transaction with .lock when accessing/modifying shared rows. Otherwise, Rails will not guarantee safety across concurrent requests.

πŸ” Best Implementation: Avoiding Race Condition with with_lock

with_lock is a built-in Rails method that applies a row-level lock on a database record. It ensures only one thread or process can read and modify the row at a time β€” until the block finishes.

πŸ’‘ Scenario:

You are deducting money from a user’s wallet. Two requests may arrive at the same time, trying to deduct funds.

🚫 Without Lock (Risky)

user = User.find(1) if user.balance >= 50 user.balance -= 50 user.save! end

Problem: Both requests read the same balance and update it without knowing the other is modifying it β€” leading to incorrect results.

βœ… With with_lock (Safe)

user = User.find(1) user.with_lock do if user.balance >= 50 user.balance -= 50 user.save! else raise \”Insufficient funds\” end end

🧠 Why It Works:

  • with_lock locks the specific row in the DB using SELECT ... FOR UPDATE.
  • Other processes must wait until the lock is released.
  • This prevents multiple updates from reading and writing stale data.

πŸ”’ Database-Side Locking

Behind the scenes, Rails sends a SELECT ... FOR UPDATE SQL query when using with_lock. It works only inside a transaction, so Rails wraps it for you.

πŸ“Œ Notes:

  • You don’t need to manually write transactions β€” with_lock uses one internally.
  • If used on an unsaved record or outside a DB-backed model, it has no effect.
  • Always use save! or update! inside the block to catch errors immediately.

πŸ” Best Implementation: Avoiding Race Condition with Optimistic Locking

Optimistic Locking is a strategy where Rails allows multiple processes to read a record, but ensures only one can update it if the data hasn’t changed in the meantime.

It relies on a special column called lock_version that tracks how many times a row has been updated. If another process has modified the record while you’re working on it, Rails will raise an error: ActiveRecord::StaleObjectError.

πŸ’‘ Scenario:

You’re building a blog platform. Two editors open the same article at the same time and make changes. Without locking, whoever saves last will overwrite the other’s edits β€” silently.

🧱 Step 1: Add lock_version to Your Table

# migration add_column :articles, :lock_version, :integer, default: 0, null: false

This column will automatically increment every time the record is updated.

πŸ§‘β€πŸ’» Step 2: Update Normally in Rails

No need to modify your controller logic. Rails handles optimistic locking automatically when lock_version is present.

article = Article.find(1) article.title = \”New Title\” article.save! # will raise StaleObjectError if someone else updated first

🚨 What Happens Behind the Scenes?

Rails includes lock_version in the UPDATE SQL query:

UPDATE articles SET title = ‘New Title’, lock_version = 2 WHERE id = 1 AND lock_version = 1;

If no rows match (because lock_version is outdated), it raises:

ActiveRecord::StaleObjectError

βœ… How to Handle This

Wrap your updates in a retry block or notify the user:

begin article = Article.find(1) article.title = \”New title!\” article.save! rescue ActiveRecord::StaleObjectError puts \”Someone else updated this article first. Please reload.\”\” end

πŸ“Œ When to Use Optimistic Locking

  • When conflicts are rare but dangerous
  • When you don’t want to block other processes (non-blocking concurrency)
  • When performance matters more than strict sequential updates

πŸ†š Optimistic vs Pessimistic Locking

FeatureOptimistic LockingPessimistic (with_lock)
Blocks other updatesNoYes
Risk of failureYes, raises StaleObjectErrorNo, other requests wait
PerformanceHighLower under heavy load
Best forLow-conflict appsFinancial or critical data

πŸ” Final Notes

  • Works only if lock_version exists in the DB.
  • No need to call with_lock or wrap in transactions manually.
  • Useful for form editing, concurrent updates, admin dashboards, etc.

πŸ”’ Best Implementation: Avoiding Race Conditions with Redis Distributed Locks

Redis distributed locks are used to safely coordinate access to shared resources across multiple threads, processes, or even servers. They’re especially useful in background job systems like Sidekiq or when multiple app servers are running.

We’ll use the redlock gem, which implements the Redlock algorithm β€” a robust way to manage distributed locks via Redis.

πŸ’‘ Scenario:

You run a flash sale where only one customer can buy the last item in stock. Multiple workers on different machines try to process the final order. You want to allow only one to succeed.

🧱 Step 1: Add Redlock to Your Gemfile

# Gemfile gem ‘redlock’
$ bundle install

βš™οΈ Step 2: Initialize the Redlock Client

You can set this up in an initializer or a service object:

# config/initializers/redlock.rb REDIS_LOCK = Redlock::Client.new([\”redis://127.0.0.1:6379\”])

πŸ” Step 3: Acquire a Lock Before Processing

Wrap the critical section (e.g., order processing) with a lock block:

REDIS_LOCK.lock(\”product:#{product.id}\”, 2000) do |locked| if locked # Critical section if product.stock > 0 product.stock -= 1 product.save! Order.create!(user_id: current_user.id, product_id: product.id) else puts \”Out of stock\” end else puts \”Could not acquire lock. Try again later.\” end end

🧠 How It Works:

  • Attempts to acquire a lock on the key product:ID for 2 seconds.
  • If the lock is granted, the code runs safely β€” no other server can acquire the same lock during that window.
  • If not, the code inside is skipped or retried.

πŸ“Œ Notes:

  • Lock timeout is important (in milliseconds). If the process crashes, Redis automatically releases the lock.
  • You can retry acquiring the lock if needed.
  • Redlock is fast and avoids DB-level locks β€” good for distributed systems.

πŸ” When to Use Redis Locks:

  • Across multiple app servers
  • In background workers (Sidekiq, Resque)
  • For unique job processing or token generation
  • When DB-level locks (like with_lock) don’t cover all processes

πŸ” Best Implementation: Avoiding Race Conditions with Idempotent Operations

Idempotent operations are designed to safely execute multiple times without changing the result after the first call. This is critical when there’s a risk of a request being duplicated β€” due to retries, background jobs, or race conditions.

Even if the same request is made twice (intentionally or by network issues), only one result should be stored or processed.

πŸ’‘ Scenario:

You have a payment endpoint. A user submits a payment, but due to a slow connection or client retries, the same request is sent multiple times. You want to charge the user only once.

πŸ“Œ Goal:

Only process the payment once β€” even if the request is submitted multiple times.

🧱 Step 1: Add Idempotency Token to the Model

# migration add_column :payments, :idempotency_key, :string, null: false add_index :payments, :idempotency_key, unique: true

This ensures no duplicate payment can be saved with the same idempotency key.

πŸ§ͺ Step 2: Handle Logic in Controller

def create idempotency_key = request.headers[\”Idempotency-Key\”] || params[:idempotency_key] return render json: { error: \”Idempotency key required\” }, status: 400 if idempotency_key.blank? payment = Payment.find_by(idempotency_key: idempotency_key) if payment # Already processed render json: payment, status: 200 else # Safe to create once payment = Payment.create!( user_id: current_user.id, amount: params[:amount], idempotency_key: idempotency_key ) render json: payment, status: 201 end end

βœ… Why This Works:

  • First request saves the record with a unique idempotency_key
  • Any repeated request with the same key will return the original result
  • Database-level uniqueness protects against race conditions

🧠 Best Practices:

  • Always index idempotency_key as unique at the DB level
  • Use UUIDs as keys generated on the frontend or client
  • Store and reuse the response for consistent results
  • Make idempotency keys expire if needed (e.g., after 24 hours)

πŸ“Œ When to Use Idempotent Operations:

  • Payment gateways and order creation
  • Email sending APIs
  • Webhook receivers
  • Retry-safe Sidekiq jobs
  • Token generation or coupon redemption

πŸ›‘οΈ Best Implementation: Avoiding Race Conditions with Database Constraints

Database constraints are rules applied directly at the database layer. They prevent invalid or duplicate data from being inserted β€” even if multiple requests try at the same time.

This is one of the most powerful ways to avoid race conditions because it doesn’t rely on application logic or timing. The DB engine itself enforces safety.

πŸ’‘ Scenario:

You want to make sure a user can only redeem a coupon code once, even if they click the submit button multiple times or if two requests hit at the same time.

🧱 Step 1: Add Uniqueness Constraint in DB

We’ll ensure that a user cannot redeem the same coupon more than once.

# migration create_table :redemptions do |t| t.references :user, null: false t.references :coupon, null: false t.timestamps end add_index :redemptions, [:user_id, :coupon_id], unique: true

This index guarantees a user can only have one redemption per coupon β€” even across multiple servers or processes.

βš™οΈ Step 2: Implement Application Logic with Rescue

def redeem_coupon Redemption.create!(user_id: current_user.id, coupon_id: params[:coupon_id]) render json: { status: ‘Redeemed!’ } rescue ActiveRecord::RecordNotUnique render json: { error: ‘You have already redeemed this coupon’ }, status: 422 end

βœ… Why This Works:

  • Even if two requests hit Redemption.create! at the same time, only one will succeed
  • The second will fail due to the DB unique index β€” caught by RecordNotUnique
  • No race condition β€” your data remains valid and safe

🧠 When to Use Database Constraints:

  • For uniqueness: emails, usernames, tokens, reference IDs
  • For presence: foreign keys (e.g., user must exist)
  • For one-to-one relationships: use composite unique indexes
  • For conditional logic: use partial unique indexes (PostgreSQL)

πŸ” Bonus: Enforcing Data with Foreign Keys

add_foreign_key :redemptions, :users add_foreign_key :redemptions, :coupons

This ensures you can’t create a redemption for a user or coupon that doesn’t exist (adds data integrity).

πŸ“Œ Final Tips:

  • Always use DB-level uniqueness in addition to Rails validations
  • Use meaningful rescue blocks to gracefully handle constraint failures
  • Don’t rely only on model-level validates_uniqueness_of β€” it’s not race-safe

πŸ”„ Best Implementation: Avoiding Race Conditions with State Machines

State machines provide a safe way to handle transitions between states, especially when there are strict rules for what state changes are allowed. This is helpful in avoiding race conditions where two processes try to transition the same record at once.

We’ll use the aasm gem to define allowed transitions and ensure they are atomic, protecting against multiple processes attempting to perform conflicting changes.

πŸ’‘ Scenario:

You’re building an order system. Orders move from pending β†’ paid β†’ shipped. Two workers may try to mark the same order as shipped β€” you want to make sure it only happens once.

🧱 Step 1: Add a status Column to Your Model

# migration add_column :orders, :status, :string, default: \”pending\”

πŸ“¦ Step 2: Add the aasm Gem

# Gemfile gem ‘aasm’ # Then run: $ bundle install

βš™οΈ Step 3: Define States and Transitions

class Order < ApplicationRecord include AASM aasm column: 'status' do state :pending, initial: true state :paid state :shipped state :cancelled event :pay do transitions from: :pending, to: :paid end event :ship do transitions from: :paid, to: :shipped end event :cancel do transitions from: [:pending, :paid], to: :cancelled end end end

πŸ”’ Step 4: Safely Transition State

Use with_lock to safely transition state in case of concurrency:

order = Order.find(1) order.with_lock do if order.may_ship? order.ship! else puts \”Order can’t be shipped now.\” end end

This ensures only one thread or process can check and perform the transition safely.

🧠 Why This Works:

  • aasm ensures valid transitions only (e.g., can’t go from shipped β†’ paid)
  • with_lock prevents race conditions from simultaneous changes
  • State is validated and changed in one atomic DB-safe operation

πŸ“Œ Best Practices

  • Use may_event? methods to check if transitions are valid before acting
  • Combine aasm with with_lock to ensure thread safety
  • Use transitions in background workers, but guard with database locks
  • Keep states explicit and minimal

πŸ“Š Example State Flow

Current StateAllowed EventsNext State
pendingpay, cancelpaid, cancelled
paidship, cancelshipped, cancelled
shipped––

🧬 Best Implementation: Avoiding Race Conditions with Job Uniqueness

Job uniqueness ensures that only one instance of a background job runs for a specific input (e.g., user ID, product ID) at a time. This prevents duplicate processing β€” a common source of race conditions in async systems like Sidekiq.

We’ll use the gem sidekiq-unique-jobs, which integrates with Sidekiq to prevent jobs with the same arguments from executing concurrently or multiple times unnecessarily.

πŸ’‘ Scenario:

You have a background job that sends a welcome email. The job may be triggered multiple times if a user signs up repeatedly or an admin retries the operation. You want to ensure the job runs only once per user.

πŸ“¦ Step 1: Add the Required Gems

# Gemfile gem ‘sidekiq’ gem ‘sidekiq-unique-jobs’
$ bundle install

βš™οΈ Step 2: Configure Sidekiq and Unique Jobs

# config/initializers/sidekiq.rb require ‘sidekiq’ require ‘sidekiq-unique-jobs’ Sidekiq.configure_server do |config| config.server_middleware do |chain| chain.add SidekiqUniqueJobs::Server::Middleware end SidekiqUniqueJobs.configure do |unique_config| unique_config.lock_info = true end end

πŸ§ͺ Step 3: Define a Unique Worker

Add uniqueness options to your worker to prevent duplicate jobs.

class WelcomeEmailWorker include Sidekiq::Worker sidekiq_options queue: :default, retry: true, lock: :until_executed, # Only one job allowed until execution completes lock_timeout: 5, # Wait max 5 seconds to acquire lock unique_args: ->(args) { [args.first] } # Only unique per user_id def perform(user_id) user = User.find(user_id) UserMailer.welcome_email(user).deliver_now end end

πŸ“Œ Lock Strategies (for lock:)

StrategyDescriptionWhen to Use
:until_executedLocks job until it starts and finishesMost common β€” prevents double work
:until_expiredLocks job until TTL expiresUseful for periodic polling jobs
:until_and_while_executingLocks both during queue and executionVery strict β€” only one instance ever

🧠 Why This Works:

  • Prevents two workers from processing the same job at once
  • Handles network retries and async queuing safely
  • Works across distributed systems β€” even multiple Sidekiq instances

🚨 Without Uniqueness (Bug)

If this job is queued twice quickly without a lock, it might send two welcome emails β€” or even process a payment twice.

βœ… With Uniqueness

The second job is skipped or blocked depending on lock strategy.

πŸ“ Best Practices:

  • Always lock time-sensitive or critical operations
  • Use Redis monitoring to debug lock acquisition failures
  • Test lock behavior in staging before production
  • Consider using idempotency_key + job uniqueness together for extra safety

♻️ Best Implementation: Avoiding Race Conditions with Retry & Rescue Mechanisms

Race conditions can still happen even with precautions like with_lock or lock_version. In these cases, using retry/rescue logic helps your application gracefully recover and reattempt failed operations.

This is particularly useful when:

  • You’re using optimistic locking and encounter StaleObjectError
  • Background jobs fail due to temporary DB or Redis contention
  • External services return 5xx errors during concurrent usage

πŸ’‘ Scenario:

Two users update the same blog post at the same time. You’re using lock_version (optimistic locking), and you want to auto-retry once on failure before showing an error.

🧱 Step 1: Add lock_version for Optimistic Locking

add_column :articles, :lock_version, :integer, default: 0, null: false

πŸ” Step 2: Add Retry Logic with Rescue

We’ll attempt the operation up to 3 times before giving up.

def update_article(article_id, params) retries = 0 begin article = Article.find(article_id) article.assign_attributes(params) article.save! rescue ActiveRecord::StaleObjectError retries += 1 if retries < 3 sleep(0.1) # small delay to reduce immediate collision retry else Rails.logger.error(\"Failed to update article due to concurrent modification\") raise end end end

🧠 Why This Works:

  • Handles instead of crashing the app
  • Retries only when a known error occurs (like StaleObjectError)
  • Limits retries to avoid infinite loops

πŸ§ͺ Optional: Sidekiq Retry-Friendly Worker

class SafePaymentWorker include Sidekiq::Worker sidekiq_options retry: 5 def perform(payment_id) payment = Payment.find(payment_id) payment.with_lock do if payment.status == ‘pending’ payment.process! end end rescue => e Rails.logger.error(\”Payment #{payment_id} failed: #{e.message}\”) raise e # allow retry if Sidekiq is configured end end

πŸ›  Best Practices:

  • Only rescue **specific errors** like StaleObjectError or Net::OpenTimeout
  • Set a retry limit (e.g., 2–3 attempts)
  • Add a delay between retries to reduce load
  • Log errors clearly for traceability

πŸ“Œ Good Use Cases:

  • Optimistic locking with `lock_version`
  • External APIs with temporary failures
  • Jobs that fail due to temporary DB contention
  • Critical data updates where retry is better than failure

πŸ“¦ Best Implementation: Avoiding Race Conditions with Versioned APIs & Queuing

Versioned APIs help structure logic cleanly over time, but in the context of race condition prevention, we pair API versioning with job queuing to ensure all changes happen in the correct order.

This combination is especially useful for:

  • Processing financial transactions
  • Stock or inventory changes
  • Voting systems or user actions that must be in sequence

πŸ’‘ Scenario:

You are building an API endpoint to let users vote on a post. If a user sends multiple vote requests in rapid succession or across devices, the system should queue them and process only one at a time.

πŸ” Step 1: Version the API

# config/routes.rb namespace :api do namespace :v1 do post ‘posts/:id/vote’, to: ‘votes#create’ end end

πŸ“¨ Step 2: Enqueue the Action

# app/controllers/api/v1/votes_controller.rb class Api::V1::VotesController < ApplicationController def create VoteQueueWorker.perform_async(current_user.id, params[:id]) render json: { message: \"Vote received and queued\" }, status: :accepted end end

🧠 Why Queueing Helps:

  • Processes are handled in FIFO order, ensuring sequence
  • Prevents multiple votes or actions hitting DB concurrently
  • Works across distributed systems and multiple servers

βš™οΈ Step 3: Process in Worker with Locking

class VoteQueueWorker include Sidekiq::Worker sidekiq_options queue: :votes, lock: :until_executed, unique_args: ->(args) { [args.first] } def perform(user_id, post_id) user = User.find(user_id) post = Post.find(post_id) # Use with_lock or validations to protect post.with_lock do unless post.votes.exists?(user_id: user.id) post.votes.create!(user: user) post.increment!(:vote_count) end end end end

πŸ“Œ Why This Works:

  • Each API call goes through a versioned route for structured evolution
  • Jobs are queued and processed one at a time
  • with_lock ensures the vote counter and relation update is safe

🚦 Best Practices:

  • Use Sidekiq Unique Jobs or Redis locks to guarantee single-job processing
  • Always return a 202 Accepted response for queued API actions
  • Use API versioning to evolve logic safely without breaking existing clients
  • Make actions idempotent β€” e.g., only one vote per user per post

πŸ§ͺ Response Format

{ \”message\”: \”Vote received and queued\”, \”status\”: \”processing\” }

πŸ’‘ 10 Examples

  • Decreasing inventory after an order (use with_lock)
  • Processing two payments simultaneously (wrap in transaction)
  • Marking a task as complete by multiple workers
  • Granting rewards when the first 100 users sign up
  • Sending a confirmation email and updating status
  • Updating a counter field in concurrent jobs
  • Handling shopping cart updates
  • Assigning tickets or tokens uniquely
  • Modifying user credit balance in a payment system
  • Managing raffle entries or lottery-based apps

❓ 10 Interview Questions & Answers

Q1: What is a race condition?

A bug that occurs when two processes access shared data concurrently, leading to unexpected results.

Q2: How does Rails handle race conditions?

Using transactions, locks (with_lock), and optimistic locking with lock_version.

Q3: What does with_lock do?

Locks the database row until the block completes to avoid concurrent modification.

Q4: When should you use optimistic locking?

When you expect low contention but still want safety.

Q5: What error is raised on optimistic lock failure?

ActiveRecord::StaleObjectError

Q6: Can validations prevent race conditions?

No. Validations run at the application level; race conditions happen in the DB layer.

Q7: What’s the role of transactions?

They ensure all DB operations are completed successfully or rolled back.

Q8: How to simulate a race condition?

Use threads or background jobs updating the same record simultaneously.

Q9: What’s a real-world impact of ignoring race conditions?

Double charging, invalid records, or inconsistent balances.

Q10: How does Postgres help prevent race conditions?

Postgres supports row-level locks and transactional isolation levels.

🧩 Alternatives

  • Using Redis locks (e.g., Redlock)
  • Queue jobs to ensure serial processing
  • Use external services like Sidekiq Enterprise with rate limiting
  • Optimistic UI + server-side validations

🌐 Real-World Case Study

Scenario: An e-commerce site had users placing orders at the same time during flash sales. Users received emails about successful orders, but stock levels were inconsistent.

Fix: They added Product.with_lock to wrap stock deductions and order creation in a transaction. It prevented overselling and kept inventory accurate.

Learn more aboutΒ RailsΒ setup

2 thoughts on “How to Prevent Race Conditions in Ruby on Rails: Complete Guide”

  1. Very well written information. It will be valuable to anybody who usess it, as well as myself. Keep up the good work – i will definitely read more posts.

  2. hello!,I love your writing so so much! share we keep in touch more about your post on AOL? I require a specialist on this house to solve my problem. Maybe that’s you! Taking a look forward to look you.

Comments are closed.

Scroll to Top