ruby 62 lines · 3 tabs

Optimistic Locking for Collaborative Edits

Shared by codesnips Jan 2026
3 tabs
class AddOptimisticLockingToDocuments < ActiveRecord::Migration[7.1]
  def change
    add_column :documents, :lock_version, :integer, null: false, default: 0
    add_index :documents, :updated_at
  end
end
3 files · ruby Explain with highlit

Optimistic locking is a concurrency strategy that assumes conflicts are rare: instead of holding a database lock while a user edits, each record carries a version counter and a write is rejected if the version changed since it was read. This suits collaborative editing, where two people may open the same document but genuinely simultaneous saves are uncommon, and holding pessimistic locks across human think-time would be disastrous for throughput.

Rails supports this natively through a magic lock_version column. The add_optimistic_locking migration in the first tab adds lock_version as a NOT NULL DEFAULT 0 integer to documents, plus an updated-at index to help ordering. Once that column exists, ActiveRecord automatically includes lock_version in the WHERE clause of every UPDATE and increments it, raising ActiveRecord::StaleObjectError when zero rows match.

The Document model shows how the client's known version is threaded through the write. apply_edit accepts the expected_version the editor last saw and assigns it to self.lock_version before saving. That assignment is what makes the check meaningful: it forces ActiveRecord to compare against the version the client believed was current, not whatever happens to be loaded in memory. The conflicting_since helper surfaces the competing edits so the UI can show what changed.

The DocumentsController translates the mechanics into an HTTP contract. On a normal save it returns the new lock_version, which the client must echo back on its next PATCH. The rescue_from ActiveRecord::StaleObjectError handler is the crux: rather than letting the exception 500, it reloads the record, returns 409 Conflict, and includes the server's current content and version so the front end can present a merge or overwrite choice.

The trade-off is that losers of a race must retry — optimistic locking never blocks, but it can reject. It shines when contention is low and rejection is cheap to recover from, and it degrades poorly under heavy write contention on a single row, where a pessimistic lock or CRDT would fit better. A subtle pitfall: forgetting to send expected_version back defeats the whole mechanism, so the API makes it a required parameter.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
payload = {
  sub: user.id,
  iss: 'https://auth.example.com',
  aud: 'codesnips-api',
  exp: 15.minutes.from_now.to_i,
  iat: Time.now.to_i,

JWT issuance and verification without common footguns

jwt authentication api
by Kai Nakamura 2 tabs
typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

Optimistic Locking for Collaborative Edits — share card
Link copied