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
class Document < ApplicationRecord
has_many :revisions, dependent: :destroy
validates :title, presence: true
def apply_edit(new_body:, editor:, expected_version:)
self.lock_version = expected_version
self.body = new_body
transaction do
save!
revisions.create!(body: new_body, author: editor, version: lock_version)
end
true
end
def conflicting_since(version)
revisions.where("version > ?", version).order(:version)
end
end
class DocumentsController < ApplicationController
rescue_from ActiveRecord::StaleObjectError, with: :render_conflict
def update
@document = Document.find(params[:id])
@document.apply_edit(
new_body: edit_params.fetch(:body),
editor: current_user,
expected_version: Integer(edit_params.fetch(:expected_version))
)
render json: {
id: @document.id,
body: @document.body,
lock_version: @document.lock_version
}
end
private
def render_conflict
fresh = @document.reload
render status: :conflict, json: {
error: "stale_edit",
message: "This document changed since you loaded it.",
current_version: fresh.lock_version,
current_body: fresh.body,
conflicting_revisions: fresh.conflicting_since(edit_params.fetch(:expected_version).to_i)
}
end
def edit_params
params.require(:document).permit(:body, :expected_version)
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
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
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
Share this code
Here's the card — post it anywhere.