π¨ 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:
- Both processes read the balance: $100
- Both calculate new balance: $100 – $60 = $40
- 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
Term | Description |
---|---|
Race Condition | Occurs when two or more processes access the same data at the same time, causing unpredictable outcomes. |
Transaction | A group of database operations that either all succeed or all fail, ensuring consistency. |
with_lock | A Rails method that locks a database row so no other operation can modify it during execution. |
Optimistic Locking | Assumes multiple updates are rare. Uses a version column (lock_version ) to detect conflicts before saving. |
StaleObjectError | Error raised when an outdated record is saved and lock_version doesnβt match the current version. |
Concurrency | When two or more processes run at the same time, possibly interacting with the same data. |
Deadlock | A situation where two processes wait for each other indefinitely, locking each other out. |
Row-Level Locking | Locks a specific row in the database during an update to prevent concurrent changes. |
Redis Locking | Uses Redis (e.g., Redlock) to coordinate locks in distributed systems to prevent race conditions. |
Thread Safety | Ensures code works correctly when accessed by multiple threads at the same time. |
π§© Types of Race Conditions & How to Avoid Them
Type | What Happens | How to Avoid |
---|---|---|
Read-Modify-Write | Two 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 Update | One update is overwritten by another, losing the first update entirely. | Use Optimistic Locking with lock_version . |
Double Execution | Two workers/processes perform the same action (e.g., send payment or email) twice. | Use idempotent logic with unique keys or state-checks. |
Deadlock | Two 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-Act | App 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 Conflict | Multiple Sidekiq jobs working on the same record at once. | Use Redis locks or with_lock in the job to serialize access. |
Counter Corruption | Incrementing/decrementing counters from multiple threads corrupts the value. | Use update_counters method which handles concurrency safely in Rails. |
Inventory Overselling | Two users buy the last product at the same time. | Use Product.with_lock when reducing stock to prevent overselling. |
Job Race on State | Two 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 Duplication | Multiple 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 / Library | Purpose | Example 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! |
redlock | Implements distributed locking using Redis β good for Sidekiq or multi-server coordination. | Redlock::Client.new([\"redis://localhost:6379\"]).lock(\"resource\", 2000) |
aasm | State machine gem for safely transitioning between states (e.g., from draft β published). | aasm do state :draft, :published; event :publish do ... end end |
sidekiq-unique-jobs | Prevents 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
Solution | Description | When to Use |
---|---|---|
Database Transactions | Ensures multiple DB operations are completed as a single unit, or rolled back together. | When multiple queries need to run together without interference. |
with_lock | Locks a database row until a block of code completes, preventing concurrent writes. | When updating a single record that could be accessed simultaneously. |
Optimistic Locking | Uses a version column (lock_version ) to detect and prevent stale updates. | When conflicts are rare but data integrity must be preserved. |
Redis Distributed Locks | Locks resources across multiple workers or servers using Redis (e.g., Redlock). | When running background jobs or services in distributed environments. |
Idempotent Operations | Designs actions so that even if repeated, the outcome stays the same. | For payment processing, email sending, or inventory updates. |
Database Constraints | Uses unique indexes or constraints to enforce consistency at the DB level. | When tokens, emails, or keys must be unique. |
State Machines | Controls 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 Uniqueness | Prevents 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 Mechanisms | Retries operations when temporary race conditions or lock failures occur. | When using optimistic locking or handling external APIs with occasional failure. |
Versioned APIs & Queuing | Queues 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)
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)
π§ 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)
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)
π§ Why It Works:
with_lock
locks the specific row in the DB usingSELECT ... 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!
orupdate!
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
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.
π¨ What Happens Behind the Scenes?
Rails includes lock_version
in the UPDATE
SQL query:
If no rows match (because lock_version is outdated), it raises:
β How to Handle This
Wrap your updates in a retry block or notify the user:
π 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
Feature | Optimistic Locking | Pessimistic (with_lock) |
---|---|---|
Blocks other updates | No | Yes |
Risk of failure | Yes, raises StaleObjectError | No, other requests wait |
Performance | High | Lower under heavy load |
Best for | Low-conflict apps | Financial 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
βοΈ Step 2: Initialize the Redlock Client
You can set this up in an initializer or a service object:
π Step 3: Acquire a Lock Before Processing
Wrap the critical section (e.g., order processing) with a lock block:
π§ 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
This ensures no duplicate payment can be saved with the same idempotency key.
π§ͺ Step 2: Handle Logic in Controller
β 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.
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
β 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
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
π¦ Step 2: Add the aasm
Gem
βοΈ Step 3: Define States and Transitions
π Step 4: Safely Transition State
Use with_lock
to safely transition state in case of concurrency:
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 fromshipped β 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
withwith_lock
to ensure thread safety - Use transitions in background workers, but guard with database locks
- Keep states explicit and minimal
π Example State Flow
Current State | Allowed Events | Next State |
---|---|---|
pending | pay, cancel | paid, cancelled |
paid | ship, cancel | shipped, 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
βοΈ Step 2: Configure Sidekiq and Unique Jobs
π§ͺ Step 3: Define a Unique Worker
Add uniqueness options to your worker to prevent duplicate jobs.
π Lock Strategies (for lock:
)
Strategy | Description | When to Use |
---|---|---|
:until_executed | Locks job until it starts and finishes | Most common β prevents double work |
:until_expired | Locks job until TTL expires | Useful for periodic polling jobs |
:until_and_while_executing | Locks both during queue and execution | Very 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
π Step 2: Add Retry Logic with Rescue
Weβll attempt the operation up to 3 times before giving up.
π§ 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
π Best Practices:
- Only rescue **specific errors** like
StaleObjectError
orNet::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
π¨ Step 2: Enqueue the Action
π§ 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
π 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
π‘ 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
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.
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.