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.
