Friday, October 24, 2025

Real-Time Inspection Updates with Turbo Streams

Paul (Founder)
Development
Developer building real-time collaborative features with Turbo Streams and WebSockets

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.