Introduction
ActiveRecord::StaleObjectError is raised when optimistic locking detects that another process has modified a record since it was last read. Ruby on Rails implements optimistic locking via a lock_version column. While this protects against lost updates, it causes errors when concurrent modifications are common in your application.
Symptoms
ActiveRecord::StaleObjectError: Attempted to update a stale object- Error occurs during concurrent API requests updating the same record
- Background jobs update records that users are also editing
- Inventory systems show conflicts when multiple orders reduce stock
- Error rate increases during peak traffic periods
Example error:
``
ActiveRecord::StaleObjectError: Attempted to update a stale object: Order
from /path/to/activerecord-7.1.3/lib/active_record/locking/optimistic.rb:110:in _update_record'
from /path/to/activerecord-7.1.3/lib/active_record/persistence.rb:1173:in create_or_update'
from app/services/order_fulfillment_service.rb:23:in fulfill'
```
Common Causes
- Multiple web requests update the same record concurrently
- Background job and web request modify same record simultaneously
- Retry logic does not reload the record before retrying
- Long-running operations hold stale records in memory
- Import/batch processing updates records being edited by users
Step-by-Step Fix
- 1.Retry with fresh record load:
- 2.```ruby
- 3.class OrderFulfillmentService
- 4.def self.fulfill(order_id, max_retries: 3)
- 5.retries = 0
- 6.begin
- 7.order = Order.find(order_id)
- 8.order.update!(status: 'fulfilled', fulfilled_at: Time.current)
- 9.rescue ActiveRecord::StaleObjectError => e
- 10.retries += 1
- 11.raise if retries > max_retries
- 12.Rails.logger.info "StaleObjectError on order #{order_id}, retry #{retries}"
- 13.sleep(rand * 0.5) # Jitter to reduce collision probability
- 14.retry
- 15.end
- 16.end
- 17.end
- 18.
` - 19.Use pessimistic locking for critical sections:
- 20.```ruby
- 21.class InventoryService
- 22.def self.reserve(product_id, quantity)
- 23.Product.transaction do
- 24.product = Product.lock.find(product_id)
if product.stock >= quantity product.update!(stock: product.stock - quantity) true else false end end end end ```
- 1.Implement atomic database operations to avoid locking:
- 2.```ruby
- 3.# Instead of read-modify-write, use SQL-level updates:
- 4.class Product < ApplicationRecord
- 5.def decrement_stock(quantity)
- 6.result = self.class.where(id: id, stock: stock)
- 7..where('stock >= ?', quantity)
- 8..update_all('stock = stock - ?', quantity)
- 9.result > 0
- 10.end
- 11.end
- 12.
` - 13.Remove optimistic locking when not needed:
- 14.```ruby
- 15.# If concurrent updates are common and conflicts are expected,
- 16.# remove the lock_version column:
- 17.class RemoveOptimisticLockingFromOrders < ActiveRecord::Migration[7.1]
- 18.def change
- 19.remove_column :orders, :lock_version, :integer
- 20.end
- 21.end
- 22.
`
Prevention
- Use
update_allfor simple counter changes instead of load-modify-save - Choose pessimistic locking (
SELECT FOR UPDATE) when conflicts are frequent - Implement retry logic with jitter for optimistic locking conflicts
- Monitor StaleObjectError rates to determine if locking strategy needs changing
- Use database-level constraints for critical data integrity (e.g.,
CHECK (stock >= 0)) - Consider event sourcing for high-concurrency domains like inventory