Building collaborative features in Rails applications often requires preventing multiple users from editing the same record simultaneously.
Traditional solutions involve optimistic locking (version checking) or building complex merge strategies. But sometimes you just want to say: “Hey, someone else is already editing this. Try again later”.
We recently implemented this pattern for an app where users needed to edit tasks without creating conflicts. The solution turned out to be surprisingly simple: lock records when users start editing, broadcast the lock status to all connected clients, and automatically release locks when users navigate away.

In this article I’ll show you a simplified version of how we built it.
Setting up the Task model
Let’s imagine we have a Task model with a title and description. Since we’re building a real-time collaborative app, we need to handle the lock for every task. To do this, we need to add a locked_byand locked_atcolumn to the taskstable:
class AddLockingToTasks < ActiveRecord::Migration[8.0]
def change
add_reference :tasks, :locked_by, foreign_key: { to_table: :users }
add_column :tasks, :locked_at, :datetime
end
end
The model itself handles locking logic and broadcasts changes to all connected users:
# app/models/task.rb
class Task < ApplicationRecord
after_create_commit { broadcast_prepend_to "tasks", target: "tasks" }
after_update_commit { broadcast_replace_to 'tasks' }
after_destroy_commit { broadcast_remove_to 'tasks' }
def locked_by?(user)
locked_by == user
end
def locked?
locked_by.present?
end
def lock!(user)
update!(locked_by: user, locked_at: Time.current)
end
def unlock!
update!(locked_by: nil, locked_at: nil)
end
end
The after_update_commit callback is key here. Every time a task is locked or unlocked, all users viewing the task list receive an updated version through Turbo Streams.
Locking tasks when editing starts
When a user clicks edit, the task locks immediately and broadcasts the change. Other users see the lock status update in real-time without refreshing.
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def edit
if @task.locked? && !@task.locked_by?(current_user)
redirect_to root_path, alert: "This task is currently being edited by #{@task.locked_by.name}."
return
end
@task.lock!(current_user)
end
def update
if @task.update(task_params)
@task.unlock!
redirect_to root_path, notice: "Task was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
def unlock
@task.unlock! if @task.locked_by?(current_user)
head :ok
end
end
Showing lock status to all users
The task partial displays different UI based on lock status:
<!-- app/views/tasks/_task.html.erb -->
<div id="<%= dom_id(task) %>">
...
<% if task.locked? %>
<%= task.locked_by %> is editing this task
<% end %>
...
<!-- Disable button if task is locked -->
<% if task.locked? %>
<button disabled> Edit </button>
<% else %>
<%= link_to "Edit", edit_task_path(task) %>
<% end %>
</div>
The index page subscribes to the Turbo Stream channel:
<!-- app/views/tasks/index.html.erb -->
<%= turbo_stream_from "tasks" %>
When any task changes (locked, unlocked, created, updated, deleted), Turbo Streams update all connected clients instantly.
The tricky part: Unlocking when users leave
Users don’t always click Save or Cancel. They might close the tab, hit the back button, or navigate elsewhere. Locks need to release automatically in all these scenarios.
This is where StimulusJS shines. The controller attaches to the edit form and listens for navigation events. The turbo:before-visit event fires when users navigate within the app (clicking Cancel, back button, or any link). The beforeunload event catches tab closures and page refreshes.
// app/javascript/controllers/task_lock_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { taskId: Number }
connect() {
this.unlock = this.unlock.bind(this)
document.addEventListener("turbo:before-visit", this.unlock)
window.addEventListener("beforeunload", this.unlock)
}
unlock() {
const formData = new FormData()
formData.append("authenticity_token", document.querySelector("[name='csrf-token']").content)
navigator.sendBeacon(`/tasks/${this.taskIdValue}/unlock`, formData)
}
}
If you didn’t notice, we used navigator.sendBeacon(). Regular AJAX requests get cancelled when pages unload, but sendBeacon() is designed specifically to send data reliably even as the page is closing.
The result
With this setup, when a user clicks Edit on a task, the task locks immediately and broadcasts the change. Other users see the lock status update in real-time without refreshing.

The beauty of this approach is its simplicity. No WebSockets to manage, no complex state synchronization, no polling. Turbo Streams and StimulusJS handle everything through Rails conventions.
Building collaborative features doesn’t always require heavy infrastructure. Sometimes the tools already in your Rails stack are enough to create smooth, real-time experiences. The key is understanding what each tool does best and combining them thoughtfully.
We’re Unagi, a software boutique building custom products and extending dev teams for companies in the US and Europe. We’ve been all-in on Ruby on Rails for 12+ years (and still loving it!). Check out more on our social media.