Letting agencies often have multiple staff members working simultaneously: one scheduling inspections whilst another reviews completed reports, one updating property details whilst another checks availability. Traditional web applications require page refreshes to see others' changes, creating confusion when staff unknowingly overwrite each other's work or schedule conflicts because calendars don't reflect recent bookings.
Week 43 implemented real-time updates using Turbo Streams, delivering instant UI synchronization across browser tabs and devices. When one staff member updates an inspection, changes appear immediately for everyone viewing that inspection—no page refresh required. This article explores Turbo Streams architecture, ActionCable integration for broadcasting, efficient DOM updates, and conflict prevention through optimistic locking.
What Your Team Will Notice
Inspections now update live across all open views. Open an inspection in two browser tabs, edit the status in one tab, and the other tab updates instantly showing the status change without refresh. Multiple staff members can view the same inspection simultaneously, each seeing others' changes as they happen.
The interface provides subtle visual feedback when updates arrive: changed sections briefly highlight showing what's new, update timestamps refresh showing current modification time, and status indicators animate when states change. This feedback makes synchronization visible without disrupting workflow.
Conflicts are prevented automatically: if two staff members edit the same inspection simultaneously, the second person to save receives a notification ("This inspection was modified by Jane 2 minutes ago. Please refresh to see changes before saving"), preventing accidental overwrites. The conflict detection compares record versions, ensuring data integrity whilst allowing parallel work on different records.
For inspections with photos or long observations, updates stream efficiently—only changed sections refresh, not entire pages. Uploading a photo adds it to the gallery instantly without reloading room observations or other content. This selective updating keeps interfaces responsive even during collaborative work.
Under the Bonnet: Turbo Streams Architecture
Turbo Streams delivers HTML fragments over WebSocket connections:
# app/models/inspection.rb
class Inspection < ApplicationRecord
# ... existing code ...
# Broadcast updates after save
after_update_commit :broadcast_update
after_destroy_commit :broadcast_removal
private
def broadcast_update
broadcast_replace_to(
"inspection_#{id}",
partial: "inspections/inspection",
locals: { inspection: self },
target: "inspection_#{id}"
)
# Also broadcast to property's inspection list
broadcast_replace_to(
"property_#{property_id}_inspections",
partial: "inspections/inspection_row",
locals: { inspection: self },
target: "inspection_row_#{id}"
)
end
def broadcast_removal
broadcast_remove_to(
"inspection_#{id}",
target: "inspection_#{id}"
)
broadcast_remove_to(
"property_#{property_id}_inspections",
target: "inspection_row_#{id}"
)
end
end
These callbacks automatically broadcast updates whenever inspections change, delivering targeted DOM replacements to subscribers.
Turbo Stream Subscription
Views subscribe to relevant broadcast streams:
<!-- app/views/inspections/show.html.erb -->
<%= turbo_stream_from "inspection_#{@inspection.id}" %>
<div id="inspection_<%= @inspection.id %>">
<%= render 'inspections/inspection', inspection: @inspection %>
</div>
<!-- app/views/properties/show.html.erb -->
<%= turbo_stream_from "property_#{@property.id}_inspections" %>
<div id="property_inspections">
<h3>Inspections</h3>
<% @property.inspections.each do |inspection| %>
<div id="inspection_row_<%= inspection.id %>">
<%= render 'inspections/inspection_row', inspection: inspection %>
</div>
<% end %>
</div>
The turbo_stream_from helper establishes WebSocket subscriptions, receiving updates for specified channels automatically.
ActionCable Channel Configuration
Turbo uses ActionCable for WebSocket transport:
# config/cable.yml
production:
adapter: redis
url: <%= ENV['REDIS_URL'] %>
channel_prefix: letadmin_production
development:
adapter: async
# app/channels/turbo_streams_channel.rb
class Turbo::StreamsChannel < ActionCable::Channel::Base
def subscribed
# Turbo handles subscription automatically
# but we can add custom authorization here
if subscription_authorized?
stream_from verified_stream_name
else
reject
end
end
private
def subscription_authorized?
# Ensure user can access this inspection/property
case params[:signed_stream_name]
when /inspection_(\d+)/
inspection_id = Regexp.last_match(1)
Inspection.find(inspection_id).agency == current_tenant
when /property_(\d+)/
property_id = Regexp.last_match(1)
Property.find(property_id).agency == current_tenant
else
false
end
rescue ActiveRecord::RecordNotFound
false
end
def current_tenant
# Extract tenant from connection
connection.current_tenant
end
end
This channel adds authorization ensuring users only subscribe to streams for their agency's data.
Selective DOM Updates
Turbo Streams supports seven actions for targeted updates:
# app/models/inspection.rb
def broadcast_status_change
# Replace only the status section
broadcast_replace_to(
"inspection_#{id}",
partial: "inspections/status",
locals: { inspection: self },
target: "inspection_status_#{id}"
)
end
def broadcast_new_photo(photo)
# Append photo to gallery
broadcast_append_to(
"inspection_#{id}",
partial: "inspection_photos/photo",
locals: { photo: photo },
target: "inspection_photos_#{id}"
)
end
def broadcast_observation_update(room)
# Update specific room observation
broadcast_update_to(
"inspection_#{id}",
partial: "inspections/room_observation",
locals: { inspection: self, room: room },
target: "room_observation_#{id}_#{room.parameterize}"
)
end
These targeted actions minimize bandwidth and prevent flickering by updating only changed content.
Optimistic Locking for Conflict Prevention
Optimistic locking detects concurrent modifications:
# db/migrate/...add_lock_version_to_inspections.rb
class AddLockVersionToInspections < ActiveRecord::Migration[7.0]
def change
add_column :inspections, :lock_version, :integer, default: 0, null: false
end
end
# app/models/inspection.rb
class Inspection < ApplicationRecord
# ActiveRecord automatically manages lock_version
end
# app/controllers/inspections_controller.rb
class InspectionsController < ApplicationController
def update
@inspection = Inspection.find(params[:id])
if @inspection.update(inspection_params)
redirect_to @inspection, notice: 'Inspection updated'
else
if @inspection.errors[:lock_version].present?
# Concurrent modification detected
@inspection.reload
flash.now[:alert] = 'This inspection was modified by another user. Please review changes and try again.'
render :edit, status: :conflict
else
render :edit, status: :unprocessable_entity
end
end
end
private
def inspection_params
# Include lock_version from hidden form field
params.require(:inspection).permit(
:inspection_type, :inspection_datetime, :notes, :status,
:lock_version, # Important: include for optimistic locking
room_observations: {}
)
end
end
Optimistic locking compares the lock_version in the form with the current database value, raising ActiveRecord::StaleObjectError if they differ.
Form Integration
Forms include lock_version for conflict detection:
<!-- app/views/inspections/_form.html.erb -->
<%= form_with(model: @inspection) do |form| %>
<!-- Hidden field for optimistic locking -->
<%= form.hidden_field :lock_version %>
<!-- Other form fields -->
<%= form.text_field :inspection_type %>
<%= form.datetime_field :inspection_datetime %>
<%= form.text_area :notes %>
<%= form.submit 'Save' %>
<% end %>
The hidden lock_version field ensures the form data matches the current record version.
Real-Time Status Indicators
Live indicators show update activity:
<!-- app/views/inspections/_inspection.html.erb -->
<div id="inspection_<%= inspection.id %>"
data-controller="live-indicator"
data-live-indicator-id-value="<%= inspection.id %>">
<div class="inspection-header">
<h2><%= inspection.property.display_address %></h2>
<span data-live-indicator-target="badge" class="live-badge hidden">
Live
</span>
</div>
<!-- Inspection content -->
<div id="inspection_status_<%= inspection.id %>">
<%= render 'inspections/status', inspection: inspection %>
</div>
<div class="last-updated">
Updated <%= time_ago_in_words(inspection.updated_at) %> ago
<% if inspection.updated_by.present? %>
by <%= inspection.updated_by.name %>
<% end %>
</div>
</div>
<script>
// app/javascript/controllers/live_indicator_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["badge"]
static values = { id: String }
connect() {
this.observeUpdates()
}
observeUpdates() {
// Show "Live" badge briefly when updates arrive
const element = this.element
// MutationObserver detects DOM changes from Turbo Streams
const observer = new MutationObserver((mutations) => {
this.flashLiveBadge()
})
observer.observe(element, {
childList: true,
subtree: true,
attributes: true
})
}
flashLiveBadge() {
this.badgeTarget.classList.remove('hidden')
// Hide after 2 seconds
setTimeout(() => {
this.badgeTarget.classList.add('hidden')
}, 2000)
}
}
</script>
This indicator provides visual confirmation when updates arrive from other users.
Testing Turbo Streams
Testing broadcasts requires system tests with JavaScript:
RSpec.describe "Inspection live updates", type: :system, js: true do
let(:inspection) { create(:inspection) }
before do
driven_by(:selenium_headless)
end
it "updates inspection status in real-time" do
# Open inspection in browser
visit inspection_path(inspection)
expect(page).to have_content("Status: Draft")
# Simulate another user updating status
using_session(:other_user) do
inspection.update!(status: 'completed')
end
# Wait for Turbo Stream update
expect(page).to have_content("Status: Completed", wait: 5)
end
it "shows new photos instantly" do
visit inspection_path(inspection)
photo_count = all('.inspection-photo').count
# Another user adds photo
using_session(:other_user) do
photo = create(:inspection_photo, inspection: inspection)
inspection.broadcast_new_photo(photo)
end
# Verify photo appears without refresh
expect(page).to have_css('.inspection-photo', count: photo_count + 1, wait: 5)
end
end
These system tests verify broadcasts and DOM updates work correctly with real WebSocket connections.
What's Next
The Turbo Streams foundation enables sophisticated features: presence indicators showing who's currently viewing inspections, collaborative editing with cursor positions visible, conflict resolution interfaces showing side-by-side diff views, and audit trails tracking who made which changes when.
Future enhancements might include optimistic UI updates showing local changes immediately before server confirmation, partial form sync preventing data loss during long editing sessions, real-time notifications when inspections requiring attention appear, and bandwidth optimization batching multiple updates into single transmissions.
By implementing real-time updates with Turbo Streams, LetAdmin provides collaborative inspection management where multiple staff members can work simultaneously without coordination overhead, seeing each other's changes instantly whilst maintaining data integrity through conflict detection and optimistic locking.
