After mastering Turbo Frames, I wanted to push further. What if multiple users could see each other's changes in real-time? What if new tasks appeared instantly for everyone, not just the person who created them?
That's where Turbo Streams come in. In this post, I'll show you how to add real-time functionality to your Rails app with surprisingly little code.
The Difference Between Frames and Streams
Before diving in, let's clarify the distinction:
Turbo Frames:
- Update content after user actions (clicks, form submissions)
- Only affect the current user's browser
- Replace content within frame boundaries
Turbo Streams:
- Push updates to multiple users simultaneously
- Work over WebSocket connections
- Can target any element on the page
Think of Frames as "interactive" and Streams as "live."
Setting Up Action Cable
Turbo Streams use Rails' Action Cable for WebSocket connections. Let's set it up:
1. Configure Action Cable
# config/cable.yml
development:
adapter: async
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: task_app_production
2. Mount Action Cable
# config/routes.rb
Rails.application.routes.draw do
mount ActionCable.server => '/cable'
resources :tasks
# ... other routes
end
3. Configure for Development
# config/environments/development.rb
Rails.application.configure do
# ... other config
# Action Cable endpoint configuration
config.action_cable.url = "ws://localhost:3000/cable"
config.action_cable.allowed_request_origins = [ "http://localhost:3000" ]
end
Building a Real-Time Task Board
Let's transform our task app into a real-time collaborative board where multiple users see updates instantly.
Step 1: Update the Task Model
Add broadcasting to the Task model:
# app/models/task.rb
class Task < ApplicationRecord
validates :title, presence: true
# Broadcast changes to all connected users
after_create_commit -> { broadcast_prepend_to "tasks", partial: "tasks/task", locals: { task: self } }
after_update_commit -> { broadcast_replace_to "tasks", partial: "tasks/task", locals: { task: self } }
after_destroy_commit -> { broadcast_remove_to "tasks" }
def toggle_complete!
update(completed: !completed)
end
end
Step 2: Subscribe to the Stream
In your main tasks view, subscribe to the "tasks" stream:
<!-- app/views/tasks/index.html.erb -->
<h1>Collaborative Task Board</h1>
<p><em>Updates appear in real-time for all users!</em></p>
<!-- Subscribe to the tasks stream -->
<%= turbo_stream_from "tasks" %>
<!-- New Task Form -->
<%= turbo_frame_tag "new_task" do %>
<%= render partial: "new_task_form", locals: { task: @task } %>
<% end %>
<!-- Tasks List -->
<div id="tasks">
<%= render partial: "task", collection: @tasks %>
</div>
Step 3: Update the Controller
Simplify the controller since broadcasting is handled in the model:
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def index
@tasks = Task.all.order(created_at: :desc)
@task = Task.new
end
def create
@task = Task.new(task_params)
if @task.save
# Broadcasting happens automatically in the model
@task = Task.new # Reset for the form
render :create
else
render :new, status: :unprocessable_entity
end
end
def toggle_complete
@task = Task.find(params[:id])
@task.toggle_complete!
# Update will be broadcast automatically
head :ok
end
def destroy
@task = Task.find(params[:id])
@task.destroy
# Removal will be broadcast automatically
head :ok
end
private
def task_params
params.require(:task).permit(:title, :description)
end
end
Step 4: Create Turbo Stream Templates
<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.replace "new_task" do %>
<%= render partial: "new_task_form", locals: { task: @task } %>
<% end %>
<%= turbo_stream.prepend "task_flash" do %>
<div class="flash-message">Task created successfully!</div>
<% end %>
Step 5: Update the Task Partial
Make sure each task has a unique ID for targeting:
<!-- app/views/tasks/_task.html.erb -->
<div class="task <%= 'completed' if task.completed? %>" id="<%= dom_id(task) %>">
<div class="task-header">
<h3><%= task.title %></h3>
<div class="task-actions">
<%= button_to "β", task_toggle_complete_path(task),
method: :patch,
class: "btn-complete #{'active' if task.completed?}",
title: task.completed? ? "Mark incomplete" : "Mark complete" %>
<%= button_to "β", task_path(task),
method: :delete,
class: "btn-delete",
title: "Delete task",
confirm: "Are you sure?" %>
</div>
</div>
<p class="task-description"><%= task.description %></p>
<p class="task-meta">
Created <%= time_ago_in_words(task.created_at) %> ago
</p>
</div>
Testing Real-Time Functionality
To see the magic in action:
- Open multiple browser tabs to
http://localhost:3000/tasks
- Create a task in one tab
- Watch it appear instantly in all other tabs
- Toggle completion in one tab
- See the update in all tabs immediately
It's genuinely magical when you see it working!
Advanced Stream Patterns
1. User-Specific Streams
Send updates only to specific users:
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :user
after_create_commit -> { broadcast_to_user }
private
def broadcast_to_user
broadcast_prepend_to "user_#{user.id}_tasks",
partial: "tasks/task",
locals: { task: self }
end
end
<!-- In the view -->
<%= turbo_stream_from "user_#{current_user.id}_tasks" %>
2. Conditional Broadcasting
Only broadcast certain changes:
# app/models/task.rb
class Task < ApplicationRecord
after_update_commit :broadcast_if_important_change
private
def broadcast_if_important_change
if saved_change_to_completed? || saved_change_to_title?
broadcast_replace_to "tasks", partial: "tasks/task", locals: { task: self }
end
end
end
3. Rich Stream Actions
Use all available stream actions:
# In a service or controller
def archive_completed_tasks
completed_tasks = Task.where(completed: true)
completed_tasks.each do |task|
# Remove from active list
Turbo::StreamsChannel.broadcast_remove_to "tasks", target: task
# Add to archived list
Turbo::StreamsChannel.broadcast_append_to "archived_tasks",
partial: "tasks/archived_task",
locals: { task: task }
end
# Show summary message
Turbo::StreamsChannel.broadcast_update_to "tasks",
target: "summary",
partial: "tasks/summary",
locals: { archived_count: completed_tasks.count }
end
4. Temporary Notifications
Show flash messages that auto-dismiss:
# app/models/task.rb
after_create_commit :broadcast_creation_notice
private
def broadcast_creation_notice
Turbo::StreamsChannel.broadcast_append_to "tasks",
target: "notifications",
partial: "shared/notification",
locals: {
message: "#{title} was added to the board",
type: "success"
}
end
<!-- app/views/shared/_notification.html.erb -->
<div class="notification notification-<%= type %>"
data-controller="auto-dismiss"
data-auto-dismiss-delay-value="3000">
<%= message %>
</div>
Building a Live Chat Feature
Let's add a simple chat system to our task board:
Create a Comment Model
rails generate model Comment task:references content:text user:string
rails db:migrate
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :task
validates :content, :user, presence: true
after_create_commit -> { broadcast_append_to "task_#{task.id}_comments",
partial: "comments/comment",
locals: { comment: self } }
end
# app/models/task.rb
class Task < ApplicationRecord
has_many :comments, dependent: :destroy
# ... existing code
end
Add Comments Controller
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def create
@task = Task.find(params[:task_id])
@comment = @task.comments.build(comment_params)
if @comment.save
render turbo_stream: turbo_stream.replace("comment_form",
partial: "comments/form",
locals: { task: @task, comment: Comment.new })
else
render turbo_stream: turbo_stream.replace("comment_form",
partial: "comments/form",
locals: { task: @task, comment: @comment })
end
end
private
def comment_params
params.require(:comment).permit(:content, :user)
end
end
Add Routes
# config/routes.rb
resources :tasks do
resources :comments, only: [:create]
end
Create Comment Views
<!-- app/views/comments/_comment.html.erb -->
<div class="comment" id="<%= dom_id(comment) %>">
<strong><%= comment.user %>:</strong>
<span><%= comment.content %></span>
<small class="timestamp"><%= time_ago_in_words(comment.created_at) %> ago</small>
</div>
<!-- app/views/comments/_form.html.erb -->
<%= form_with model: [task, comment], class: "comment-form" do |form| %>
<div class="form-row">
<%= form.text_field :user, placeholder: "Your name", required: true %>
<%= form.text_field :content, placeholder: "Add a comment...", required: true %>
<%= form.submit "Post", class: "btn-post" %>
</div>
<% end %>
Update Task View
<!-- app/views/tasks/_task.html.erb -->
<div class="task <%= 'completed' if task.completed? %>" id="<%= dom_id(task) %>">
<!-- Existing task content -->
<!-- Comments section -->
<div class="task-comments">
<%= turbo_stream_from "task_#{task.id}_comments" %>
<div id="task_<%= task.id %>_comments">
<%= render partial: "comments/comment", collection: task.comments %>
</div>
<div id="comment_form">
<%= render partial: "comments/form", locals: { task: task, comment: Comment.new } %>
</div>
</div>
</div>
Performance Considerations
1. Limit Stream Subscribers
# Don't broadcast to too many streams
# Instead of user-specific streams for many users, consider:
after_create_commit -> { broadcast_to_room }
private
def broadcast_to_room
# Broadcast to room/project level instead of individual users
broadcast_prepend_to "project_#{project_id}_tasks"
end
2. Throttle Updates
# app/models/task.rb
class Task < ApplicationRecord
after_update_commit :broadcast_update, if: :should_broadcast?
private
def should_broadcast?
# Only broadcast significant changes
saved_change_to_title? ||
saved_change_to_completed? ||
saved_change_to_description?
end
end
3. Batch Operations
# For bulk operations, batch the streams
def complete_all_tasks
tasks = Task.where(completed: false)
tasks.update_all(completed: true)
# Single broadcast for all updates
Turbo::StreamsChannel.broadcast_replace_to "tasks",
target: "task_list",
partial: "tasks/task_list",
locals: { tasks: Task.all }
end
Debugging Stream Issues
1. Check WebSocket Connection
In browser console:
```javascript
// Check if Action Cable is connected
App.cable.connection.isOpen()
// Monitor connection events
App.cable.connection.monitor.addEventListener('connected', () => {
console.log('WebSocket connected')
})
```
2. Verify Stream Names
# In model
after_create_commit -> {
Rails.logger.info "Broadcasting to: tasks"
broadcast_prepend_to "tasks"
}
3. Test Without Broadcasting
# Temporarily disable to test UI
# after_create_commit -> { broadcast_prepend_to "tasks" }
Common Pitfalls
1. N+1 Queries in Broadcasts
# Bad
after_create_commit -> { broadcast_prepend_to "tasks" }
# Good - ensure associations are loaded
after_create_commit -> { broadcast_prepend_to "tasks", partial: "tasks/task", locals: { task: self } }
2. Broadcasting in Loops
# Bad - creates many broadcasts
tasks.each { |task| task.update(completed: true) }
# Good - batch update, single broadcast
Task.where(id: task_ids).update_all(completed: true)
broadcast_replace_to "tasks", partial: "tasks/list"
3. Missing Stream Subscriptions
Always ensure views include <%= turbo_stream_from "stream_name" %>
What I Learned
Building real-time features with Turbo Streams taught me:
- WebSockets can be simple - No need for complex JavaScript libraries
- Broadcasting is powerful - Small changes create big user experience improvements
- Performance matters - Don't broadcast everything to everyone
- Testing is crucial - Real-time features need careful testing
- Progressive enhancement works - Features work without WebSockets, better with them
Next Steps
With Turbo Streams mastered, you can build:
- Collaborative editing - Multiple users editing the same document
- Live notifications - Real-time alerts and updates
- Activity feeds - Live streams of user actions
- Chat systems - Real-time messaging
- Live dashboards - Metrics that update automatically
In my next post, I'll show you how to add JavaScript behavior with Stimulus controllers, completing the Hotwire toolkit.
Key takeaways:
- Turbo Streams enable real-time multi-user features
- WebSocket setup is handled by Action Cable
- Broadcasting happens in model callbacks
- Multiple stream actions can target different page elements
- Performance and debugging are crucial for production apps
Try adding real-time features to your own app. Start with something simple like live notifications, then expand to more collaborative features!
Next up: "Stimulus Controllers: Adding Just Enough JavaScript" - We'll learn how to add client-side behavior while keeping the server-side mindset.