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. 1.Retry with fresh record load:
  2. 2.```ruby
  3. 3.class OrderFulfillmentService
  4. 4.def self.fulfill(order_id, max_retries: 3)
  5. 5.retries = 0
  6. 6.begin
  7. 7.order = Order.find(order_id)
  8. 8.order.update!(status: 'fulfilled', fulfilled_at: Time.current)
  9. 9.rescue ActiveRecord::StaleObjectError => e
  10. 10.retries += 1
  11. 11.raise if retries > max_retries
  12. 12.Rails.logger.info "StaleObjectError on order #{order_id}, retry #{retries}"
  13. 13.sleep(rand * 0.5) # Jitter to reduce collision probability
  14. 14.retry
  15. 15.end
  16. 16.end
  17. 17.end
  18. 18.`
  19. 19.Use pessimistic locking for critical sections:
  20. 20.```ruby
  21. 21.class InventoryService
  22. 22.def self.reserve(product_id, quantity)
  23. 23.Product.transaction do
  24. 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. 1.Implement atomic database operations to avoid locking:
  2. 2.```ruby
  3. 3.# Instead of read-modify-write, use SQL-level updates:
  4. 4.class Product < ApplicationRecord
  5. 5.def decrement_stock(quantity)
  6. 6.result = self.class.where(id: id, stock: stock)
  7. 7..where('stock >= ?', quantity)
  8. 8..update_all('stock = stock - ?', quantity)
  9. 9.result > 0
  10. 10.end
  11. 11.end
  12. 12.`
  13. 13.Remove optimistic locking when not needed:
  14. 14.```ruby
  15. 15.# If concurrent updates are common and conflicts are expected,
  16. 16.# remove the lock_version column:
  17. 17.class RemoveOptimisticLockingFromOrders < ActiveRecord::Migration[7.1]
  18. 18.def change
  19. 19.remove_column :orders, :lock_version, :integer
  20. 20.end
  21. 21.end
  22. 22.`

Prevention

  • Use update_all for 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