Preventing edit conflicts in Rails with Turbo/Hotwire and StimulusJS

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.