Monday, October 27, 2025

Refactoring Real-Time Updates: From Turbo Streams to ActionCable + Alpine.js

Paul (Founder)
Development
Cat helping developer refactor real-time architecture for better separation of concerns

Week 43's Turbo Streams implementation provided working real-time updates but introduced architectural complexity. Broadcasting HTML fragments required server-side partials matching client DOM structure exactly, Alpine.js components lost state when DOM replaced, and debugging involved examining HTML strings in WebSocket messages. These pain points suggested a simpler approach might serve better.

Week 44 refactored real-time updates replacing Turbo Streams with ActionCable broadcasting JSON plus Alpine.js managing client updates. This separation clarifies responsibilities: ActionCable handles data transport, Alpine.js handles presentation, and client-side state persists through updates. This article explores the refactoring process, architectural improvements, and lessons learned choosing simplicity over framework features.

What Your Team Will Notice

Real-time updates work identically from the user perspective—inspections still update live across tabs and devices—but the underlying implementation improved significantly. Form inputs maintain focus and selection when updates arrive (previously lost when DOM replaced), scroll positions preserve during updates, and Alpine.js component state persists without resetting.

Debug logs now show clear JSON messages: {"event": "inspection_updated", "inspection_id": 123, "status": "completed"} rather than HTML fragments. This clarity accelerates troubleshooting when broadcasts don't behave as expected, particularly in production where HTML inspection is impractical.

WebSocket payload sizes reduced by approximately 70%. Sending structured data like {"status": "completed"} consumes far less bandwidth than entire HTML partials, improving performance on slow mobile connections and reducing server bandwidth costs for high-traffic agencies.

The Problem with Turbo Streams

Turbo Streams' HTML broadcasting creates tight coupling between server and client:

# Week 43 approach - broadcasting HTML
class Inspection < ApplicationRecord
  after_update_commit :broadcast_update

  def broadcast_update
    broadcast_replace_to(
      "inspection_#{id}",
      partial: "inspections/inspection",
      locals: { inspection: self },
      target: "inspection_#{id}"
    )
  end
end

This code renders inspections/_inspection.html.erb server-side, serializes the HTML into WebSocket messages, and replaces DOM elements client-side. Several problems emerge:

State Loss: When Turbo replaces DOM elements, Alpine.js components re-initialize losing their state. Form inputs reset, collapsed sections re-expand, and user interactions disappear.

HTML in WebSockets: Debugging requires examining HTML strings. When updates don't work, determining whether the problem is partial rendering, DOM targeting, or WebSocket delivery becomes difficult.

Server-Side Presentation Logic: The server must know exactly how clients render inspections—which CSS classes, which DOM structure—creating fragile coupling when frontend evolves independently.

The Solution: JSON Events + Alpine.js

The refactored architecture broadcasts structured events:

# Week 44 approach - broadcasting JSON
class Inspection < ApplicationRecord
  after_update_commit :broadcast_update

  def broadcast_update
    ActionCable.server.broadcast(
      "inspection_#{id}",
      {
        event: 'inspection_updated',
        inspection: serialize_for_broadcast
      }
    )
  end

  private

  def serialize_for_broadcast
    {
      id: id,
      status: status,
      inspection_type: inspection_type,
      inspection_datetime: inspection_datetime.iso8601,
      updated_at: updated_at.iso8601,
      updated_by: {
        name: updated_by&.name
      },
      room_observations: room_observations,
      general_observations: general_observations
    }
  end
end

This broadcasts structured JSON describing what changed. Clients receive data and decide how to update their UI independently.

ActionCable Channel

The channel handles subscriptions with authorization:

# app/channels/inspection_channel.rb
class InspectionChannel < ApplicationCable::Channel
  def subscribed
    inspection = Inspection.find(params[:inspection_id])

    # Verify access
    reject unless inspection.agency == current_tenant

    stream_from "inspection_#{params[:inspection_id]}"
  end

  def unsubscribed
    stop_all_streams
  end
end

This channel verifies users can only subscribe to inspections belonging to their agency, maintaining multi-tenant security.

Alpine.js Component

Alpine.js handles client-side updates reactively:

// app/javascript/controllers/inspection_live_controller.js
import consumer from '../channels/consumer'

export function inspectionLive(inspectionId) {
  return {
    inspection: null,
    subscription: null,
    lastUpdate: null,

    init() {
      // Load initial data
      this.loadInspection()

      // Subscribe to updates
      this.subscription = consumer.subscriptions.create(
        {
          channel: 'InspectionChannel',
          inspection_id: inspectionId
        },
        {
          received: (data) => {
            this.handleUpdate(data)
          }
        }
      )
    },

    destroy() {
      if (this.subscription) {
        this.subscription.unsubscribe()
      }
    },

    async loadInspection() {
      const response = await fetch(`/inspections/${inspectionId}.json`)
      this.inspection = await response.json()
    },

    handleUpdate(data) {
      switch (data.event) {
        case 'inspection_updated':
          this.updateInspection(data.inspection)
          break
        case 'photo_added':
          this.addPhoto(data.photo)
          break
        case 'status_changed':
          this.updateStatus(data.status)
          break
      }

      this.lastUpdate = new Date()
      this.flashUpdateIndicator()
    },

    updateInspection(inspectionData) {
      // Merge updates preserving local state
      this.inspection = {
        ...this.inspection,
        ...inspectionData
      }
    },

    updateStatus(status) {
      this.inspection.status = status
    },

    addPhoto(photo) {
      if (!this.inspection.photos) {
        this.inspection.photos = []
      }
      this.inspection.photos.push(photo)
    },

    flashUpdateIndicator() {
      // Show visual feedback
      const indicator = this.$refs.updateIndicator
      if (indicator) {
        indicator.classList.add('flashing')
        setTimeout(() => {
          indicator.classList.remove('flashing')
        }, 1000)
      }
    }
  }
}

This component maintains inspection state locally, receives updates via WebSocket, and merges changes reactively without losing user interface state.

View Integration

The view connects Alpine.js to the data:

<!-- app/views/inspections/show.html.erb -->
<div x-data="inspectionLive(<%= @inspection.id %>)">
  <!-- Status Section -->
  <div class="status-section">
    <h3>Status</h3>
    <span class="status-badge" :class="inspection.status">
      <span x-text="inspection.status"></span>
    </span>

    <!-- Update indicator -->
    <span x-ref="updateIndicator"
          class="update-indicator"
          x-show="lastUpdate">
      Updated
      <span x-text="lastUpdate ? $filters.timeAgo(lastUpdate) : ''"></span>
    </span>
  </div>

  <!-- Room Observations -->
  <div class="observations-section">
    <h3>Observations</h3>
    <template x-for="(observations, room) in inspection.room_observations" :key="room">
      <div class="room-card">
        <h4 x-text="room.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())"></h4>
        <p x-text="observations"></p>
      </div>
    </template>
  </div>

  <!-- Photos -->
  <div class="photos-section">
    <h3>Photos</h3>
    <div class="photo-grid">
      <template x-for="photo in inspection.photos" :key="photo.id">
        <img :src="photo.url" :alt="photo.description" class="photo-thumbnail">
      </template>
    </div>
  </div>
</div>

<script>
import { inspectionLive } from '../javascript/controllers/inspection_live_controller'

// Make available to Alpine
window.inspectionLive = inspectionLive
</script>

This view binds inspection data to DOM reactively. When inspection.status changes in JavaScript, the badge updates automatically.

Migration Strategy

Refactoring required careful migration to avoid breaking production:

1. Parallel Implementation: Both Turbo Streams and ActionCable ran simultaneously initially, allowing gradual testing without removing working features.

2. Feature Flags: Environment variables controlled which system handled updates, enabling rollback if issues emerged.

3. Gradual Rollout: Inspection updates migrated first (simplest case), then property updates, then complex multi-model scenarios.

4. Cleanup: After confirming ActionCable reliability, Turbo Streams code removed, simplifying codebase.

# config/initializers/realtime_updates.rb
module RealtimeUpdates
  def self.use_turbo_streams?
    ENV.fetch('USE_TURBO_STREAMS', 'false') == 'true'
  end

  def self.use_action_cable?
    !use_turbo_streams?
  end
end

# app/models/inspection.rb
class Inspection < ApplicationRecord
  after_update_commit :broadcast_update

  def broadcast_update
    if RealtimeUpdates.use_turbo_streams?
      broadcast_via_turbo_streams
    elsif RealtimeUpdates.use_action_cable?
      broadcast_via_action_cable
    end
  end
end

This gradual approach prevented big-bang refactoring risks whilst ensuring production stability.

Performance Comparison

The refactored architecture improved performance measurably:

Payload Size: Turbo Streams broadcast 8KB HTML fragments average; ActionCable broadcasts 2KB JSON messages—75% reduction.

Client-Side Processing: Alpine.js updates individual bindings; Turbo Streams replaced entire DOM subtrees triggering re-initialization overhead.

Debugging Time: JSON inspection takes seconds; HTML fragment debugging took minutes tracing through server-side partials.

Testing Real-Time Updates

Testing ActionCable + Alpine.js requires different approaches than Turbo Streams:

RSpec.describe InspectionChannel, type: :channel do
  let(:inspection) { create(:inspection) }
  let(:user) { inspection.conducted_by }

  before do
    stub_connection(current_user: user, current_tenant: user.agency)
  end

  it "subscribes to inspection updates" do
    subscribe(inspection_id: inspection.id)

    expect(subscription).to be_confirmed
    expect(streams).to include("inspection_#{inspection.id}")
  end

  it "broadcasts JSON updates when inspection changes" do
    subscribe(inspection_id: inspection.id)

    expect {
      inspection.update!(status: 'completed')
    }.to have_broadcasted_to("inspection_#{inspection.id}")
      .with(hash_including(
        event: 'inspection_updated',
        inspection: hash_including(status: 'completed')
      ))
  end

  it "rejects unauthorized subscriptions" do
    other_agency_inspection = create(:inspection)

    subscribe(inspection_id: other_agency_inspection.id)

    expect(subscription).to be_rejected
  end
end

These tests verify channel authorization and broadcast content directly without examining HTML.

What's Next

The ActionCable + Alpine.js architecture enables sophisticated features: presence tracking showing who views inspections currently, optimistic updates showing local changes before server confirmation, offline queueing storing updates when disconnected, and granular event subscriptions subscribing to specific update types reducing bandwidth.

Future enhancements might include WebSocket reconnection with exponential backoff, broadcast batching combining multiple rapid updates into single messages, state synchronization verifying client state matches server periodically, and metrics tracking broadcast latency and message delivery rates.

By refactoring from Turbo Streams to ActionCable + Alpine.js, LetAdmin achieved clearer separation of concerns, better client-side state management, and easier debugging—demonstrating that sometimes the best technical decision involves stepping back, reassessing architecture, and choosing simpler patterns matching actual requirements rather than framework defaults.