Real-Time Features
March 19, 2026 · View on GitHub
Listopia uses Hotwire - Turbo Streams + Stimulus to deliver real-time collaborative experiences. The philosophy is simple: prefer Turbo Streams for all real-time updates, use Stimulus only when Turbo cannot solve the problem.
Architecture
Turbo Streams (Primary)
Turbo Streams send HTML updates over WebSocket connections, replacing or appending DOM elements in real-time. This is the primary mechanism for all real-time features.
Model Changes → Broadcast Job → Turbo Stream → Browser DOM Update
Stimulus Controllers (Last Resort)
Stimulus handles client-side logic when Turbo Streams are insufficient. Examples: drag-and-drop with custom sorting, form field validation, animation triggers.
Technology Stack
- Turbo Drive - Fast page navigation
- Turbo Frames - Scoped page sections
- Turbo Streams - Real-time HTML updates
- Action Cable - WebSocket server
- Stimulus - JavaScript controller framework
Turbo Streams Implementation
Broadcasting from Models
Models broadcast changes automatically via callbacks:
# app/models/list_item.rb
class ListItem < ApplicationRecord
belongs_to :list
# Broadcast on create, update, destroy
after_create_commit :broadcast_created
after_update_commit :broadcast_updated
after_destroy_commit :broadcast_destroyed
private
def broadcast_created
broadcast_to_list_collaborators(:created)
end
def broadcast_updated
broadcast_to_list_collaborators(:updated)
end
def broadcast_destroyed
broadcast_to_list_collaborators(:destroyed)
end
def broadcast_to_list_collaborators(action)
# Get all users viewing this list
target_users = list.collaborators + [list.owner]
target_users.each do |user|
# Broadcast to each user's personal stream
Turbo::StreamsChannel.broadcast_render_to(
"list_#{list.id}_user_#{user.id}",
template: "list_items/#{action}",
locals: { item: self, list: list, user: user }
)
end
end
end
Stream Templates
Define what gets sent to the browser:
<!-- app/views/list_items/_created.turbo_stream.erb -->
<%= turbo_stream.append "list_items_#{list.id}" do %>
<%= render "list_items/item", item: item, list: list %>
<% end %>
<%= turbo_stream.replace "list_progress_#{list.id}" do %>
<%= render "lists/progress_bar", list: list %>
<% end %>
Controller Integration
Make controllers respond with Turbo Streams:
# app/controllers/list_items_controller.rb
class ListItemsController < ApplicationController
def create
@list = current_user.lists.find(params[:list_id])
@item = @list.list_items.build(item_params)
if @item.save
# Automatically renders list_items/create.turbo_stream.erb
respond_to do |format|
format.turbo_stream
format.html { redirect_to @list }
end
else
render :new, status: :unprocessable_content
end
end
def update
@item = current_user.accessible_list_items.find(params[:id])
if @item.update(item_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @item.list }
end
else
render :edit, status: :unprocessable_content
end
end
def toggle_completion
@item = current_user.accessible_list_items.find(params[:id])
@list = @item.list
@item.update(
status: @item.status_completed? ? :pending : :completed,
completed_at: @item.status_completed? ? nil : Time.current
)
respond_to do |format|
format.turbo_stream
format.json { render json: { status: @item.status } }
end
end
end
Optimistic UI Updates
For immediate user feedback, update the DOM before the server responds:
<!-- app/views/list_items/item.html.erb -->
<div id="list_item_<%= item.id %>" data-item-id="<%= item.id %>">
<input
type="checkbox"
<%= "checked" if item.status_completed? %>
data-action="change->list-items#toggleCompletion"
/>
<span><%= item.title %></span>
</div>
// app/javascript/controllers/list_items_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
toggleCompletion(event) {
const checkbox = event.target
const itemId = checkbox.dataset.itemId
const itemDiv = this.element.closest(`#list_item_${itemId}`)
// Optimistic update: show change immediately
checkbox.checked ?
itemDiv.classList.add('completed') :
itemDiv.classList.remove('completed')
// Send to server (Turbo Stream response updates correctly)
fetch(`/list_items/${itemId}/toggle_completion`, {
method: 'PATCH',
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
'Accept': 'text/vnd.turbo-stream.html'
}
})
.catch(error => {
// Revert on error
checkbox.checked = !checkbox.checked
checkbox.checked ?
itemDiv.classList.add('completed') :
itemDiv.classList.remove('completed')
})
}
}
Stimulus Controllers
Use Stimulus only when Turbo cannot solve the problem. Examples:
1. Drag-and-Drop Reordering
Turbo can't handle complex drag interactions, so Stimulus is appropriate:
// app/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
export default class extends Controller {
static values = { url: String }
connect() {
this.sortable = Sortable.create(this.element, {
handle: '.drag-handle',
animation: 150,
onEnd: this.handleReorder.bind(this)
})
}
async handleReorder(event) {
const itemId = event.item.dataset.itemId
const position = event.newIndex
const response = await fetch(this.urlValue, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
'Accept': 'text/vnd.turbo-stream.html'
},
body: JSON.stringify({ item_id: itemId, position: position })
})
Turbo.renderStreamMessage(await response.text())
}
}
2. Form Validation
Real-time feedback on form fields:
// app/javascript/controllers/form_validation_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["field", "error"]
validateField(event) {
const field = event.target
const value = field.value.trim()
const errorTarget = this.errorTargets.find(
t => t.dataset.field === field.name
)
if (value.length === 0) {
errorTarget.textContent = "This field is required"
field.classList.add('border-red-500')
} else {
errorTarget.textContent = ""
field.classList.remove('border-red-500')
}
}
}
3. Dropdown Menus & Toggles
Local state changes without server round-trip:
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu", "button"]
toggle(event) {
event.preventDefault()
this.menuTarget.classList.toggle('hidden')
this.buttonTarget.classList.toggle('active')
}
close() {
this.menuTarget.classList.add('hidden')
this.buttonTarget.classList.remove('active')
}
}
Common Patterns
Pattern 1: Turbo Stream with Fallback
def create
@item = build_item
if @item.save
respond_to do |format|
format.turbo_stream # Real-time update
format.html { redirect_to @item.list } # Fallback
end
else
render :new
end
end
Pattern 2: Broadcast Only to Specific Users
def broadcast_to_list_collaborators(action)
# Only notify collaborators, not the actor
target_users = list.collaborators.where.not(id: Current.user.id)
target_users << list.owner unless list.owner == Current.user
target_users.each do |user|
Turbo::StreamsChannel.broadcast_render_to(
"list_#{list.id}_user_#{user.id}",
template: "list_items/#{action}",
locals: { item: self, list: list }
)
end
end
Pattern 3: Batch Updates
# Don't broadcast on every change, batch them
after_update_commit :broadcast_changes_later
def broadcast_changes_later
BroadcastChangesJob.set(wait: 2.seconds).perform_later(self)
end
Subscribing to Streams
In Views (Turbo Frames)
<!-- app/views/lists/show.html.erb -->
<%= turbo_frame_tag "list_items_#{@list.id}" do %>
<!-- Items rendered here -->
<%= render @list.list_items %>
<% end %>
<!-- Subscribe to real-time updates for this frame -->
<%= turbo_stream_from "list_#{@list.id}_user_#{current_user.id}" %>
Multiple Streams
<%= turbo_stream_from "list_#{@list.id}_user_#{current_user.id}" %>
<%= turbo_stream_from "notifications_user_#{current_user.id}" %>
<%= turbo_stream_from "user_presence_#{@list.id}" %>
Testing Real-Time Features
Test Broadcasting
# spec/models/list_item_spec.rb
describe ListItem do
describe "#broadcast_created" do
it "broadcasts to list collaborators" do
list = create(:list)
user = create(:user)
list.collaborators << user
expect {
list.list_items.create!(title: "New item")
}.to have_broadcasted_to("list_#{list.id}_user_#{user.id}")
end
end
end
Test Stream Responses
# spec/requests/list_items_spec.rb
describe "ListItem creation with Turbo Streams" do
it "responds with turbo stream" do
list = create(:list, owner: current_user)
post list_items_path(list), params: {
list_item: { title: "New task" }
}, headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response.status).to eq(200)
expect(response.media_type).to match("text/vnd.turbo-stream")
expect(response.body).to include("turbo-stream")
end
end
Debugging
Check Broadcasting
# In Rails console
user = User.first
list = List.first
# Trigger broadcast
list.list_items.first.update(title: "Debug test")
# Check broadcast was sent
# Look in browser console or network tab for Turbo Stream messages
Monitor WebSocket
// In browser console
// Check connection
console.log(Turbo.session)
// Listen for stream events
document.addEventListener('turbo:before-stream-render', (event) => {
console.log('Turbo Stream:', event.target.innerHTML)
})
When to Use Stimulus (Not Turbo Streams)
✅ Use Stimulus when:
- Interacting with third-party libraries (drag-and-drop, charts, maps)
- Complex local state management without server changes
- Form validation with real-time feedback
- Animations and transitions
- Preventing default browser behavior
❌ Don't use Stimulus for:
- Data updates that persist to server
- Rendering updated content from server
- Broadcasting changes to other users
- Multi-user synchronization
Use Turbo Streams instead.
Performance
Broadcasting Best Practices
- Batch rapid changes - Use
after_commitwith job delays - Only update needed elements - Use targeted selectors, not full page
- Exclude unnecessary users - Only broadcast to affected users
- Use connection pooling - Configure Action Cable for production
Optimization Example
# Instead of broadcasting on every keystroke
after_update_commit :broadcast_description_change
# Batch updates with a job
def broadcast_description_change
BroadcastChangesJob.set(wait: 1.second).perform_later(
list_id: list.id,
user_id: Current.user.id
)
end
Troubleshooting
Turbo Streams not updating?
- Check
turbo_stream_fromis in the view - Verify element IDs match between broadcast and DOM
- Check browser console for errors
- Confirm WebSocket connection in Network tab
JavaScript not running?
- Verify Stimulus controller file is in
app/javascript/controllers/ - Check data attributes match controller targets
- Ensure
import_map.jsonincludes the controller - Test in browser console:
Stimulus.application.controllers
Performance issues?
- Monitor broadcast frequency - too many updates?
- Use job delays for rapid-fire changes
- Consider pagination for large lists
- Profile with Rails query logs and network tab
Summary
Real-time architecture in Listopia:
- Models trigger broadcasts -
after_commitcallbacks - Broadcasts send Turbo Streams - HTML over WebSocket
- Streams update DOM - No page refresh needed
- Stimulus enhances interactions - Only when Turbo can't
- Progressive enhancement - Works without JavaScript
This approach provides real-time collaboration with minimal JavaScript complexity while maintaining excellent performance and user experience.