Turbo Streams enable real-time page updates over WebSocket connections without custom JavaScript. While the basics are straightforward, production applications demand more sophisticated patterns. This guide covers broadcasting strategies, custom stream actions, and patterns that scale.
Understanding Stream Architecture
Turbo Streams work through two delivery mechanisms: inline responses from controller actions and broadcasts over Action Cable. The inline approach handles form submissions, while broadcasts push updates to all subscribed clients simultaneously.
The key insight: streams modify the DOM using seven built-in actions—append, prepend, replace, update, remove, before, and after. Each action targets elements by DOM ID and applies changes atomically.
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
broadcasts_to :post, inserts_by: :prepend, target: "comments"
after_create_commit :notify_post_author
private
def notify_post_author
broadcast_append_to(
post.user,
target: "notifications",
partial: "notifications/comment",
locals: { comment: self }
)
end
endThe broadcasts_to macro handles create, update, and destroy callbacks automatically. The association-based stream name ensures only users viewing that specific post receive updates. Custom broadcasts in callbacks provide granular control for cross-cutting concerns like notifications.
Controller Response Patterns
Inline Turbo Stream responses work best for actions where the initiating user needs different feedback than passive observers. Consider a task completion feature where the user clicking "Complete" sees immediate confirmation while teammates see a status update.
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def complete
@task = Current.user.tasks.find(params[:id])
@task.complete!
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(@task, partial: "tasks/task", locals: { task: @task }),
turbo_stream.update("flash", partial: "shared/flash", locals: { message: "Task completed!" }),
turbo_stream.update("task_count", Current.user.tasks.pending.count)
]
end
format.html { redirect_to tasks_path, notice: "Task completed!" }
end
end
endMultiple stream actions in a single response enable coordinated UI updates. The array syntax keeps related changes together, while the HTML fallback ensures functionality without JavaScript.
Stream Authorization and Security
Stream subscriptions require explicit authorization. Without it, any user could subscribe to any stream name and receive sensitive data. Rails 8 provides the Turbo::StreamsChannel with built-in verification.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if verified_user = User.find_by(id: cookies.encrypted[:user_id])
verified_user
else
reject_unauthorized_connection
end
end
end
end
# app/models/project.rb
class Project < ApplicationRecord
has_many :memberships
has_many :users, through: :memberships
has_many :tasks
def broadcast_target
"project_#{id}_tasks"
end
end
# app/views/projects/show.html.erb
<%= turbo_stream_from @project, :tasks if @project.users.include?(Current.user) %>The signed stream name generated by turbo_stream_from prevents tampering. Verifying membership before rendering the subscription tag adds application-level authorization. Streams broadcast to that channel reach only authorized, connected users.
Custom Stream Actions
Built-in actions cover most scenarios, but complex interfaces sometimes need custom behavior. Rails 8 supports registering custom Turbo Stream actions that execute arbitrary JavaScript.
// app/javascript/turbo_streams/highlight_action.js
import { StreamActions } from "@hotwired/turbo"
StreamActions.highlight = function() {
const target = this.targetElements[0]
if (!target) return
target.classList.add("highlight-pulse")
setTimeout(() => target.classList.remove("highlight-pulse"), 2000)
}
// app/javascript/application.js
import "./turbo_streams/highlight_action"
# Usage in Ruby:
# turbo_stream.action(:highlight, "comment_#{@comment.id}")Custom actions bridge the gap between declarative stream updates and imperative JavaScript. The targetElements property provides access to matched DOM elements, enabling animations, focus management, or third-party library integration.
Broadcasting from Background Jobs
Long-running operations should broadcast from Solid Queue jobs rather than blocking web requests. This pattern keeps response times fast while ensuring updates reach users when processing completes.
# app/jobs/report_generation_job.rb
class ReportGenerationJob < ApplicationJob
queue_as :default
def perform(report)
report.update!(status: "processing")
broadcast_status(report)
result = ReportGenerator.new(report).generate
report.update!(status: "completed", file: result)
broadcast_completion(report)
rescue StandardError => e
report.update!(status: "failed", error_message: e.message)
broadcast_status(report)
raise
end
private
def broadcast_status(report)
Turbo::StreamsChannel.broadcast_replace_to(
report.user,
target: "report_#{report.id}",
partial: "reports/report",
locals: { report: report }
)
end
def broadcast_completion(report)
Turbo::StreamsChannel.broadcast_append_to(
report.user,
target: "downloads",
partial: "reports/download_link",
locals: { report: report }
)
end
endBroadcasting directly from jobs via Turbo::StreamsChannel bypasses model callbacks. This explicit approach provides clearer control flow and error handling. Status transitions broadcast incrementally, keeping users informed throughout processing.
Handling Disconnections and Race Conditions
WebSocket connections drop. Users switch tabs, lose network connectivity, or close laptops. Robust applications handle reconnection gracefully by reconciling state.
Turbo automatically reconnects dropped connections and resubscribes to streams. However, updates broadcast during disconnection are lost. For critical data, implement a staleness check that triggers a full refresh when reconnection occurs.
// app/javascript/controllers/reconnect_controller.js
import { Controller } from "@hotwired/stimulus"
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"
export default class extends Controller {
static values = { url: String }
connect() {
document.addEventListener("turbo:connected", this.handleReconnect)
}
disconnect() {
document.removeEventListener("turbo:connected", this.handleReconnect)
}
handleReconnect = () => {
if (this.wasDisconnected) {
fetch(this.urlValue, { headers: { Accept: "text/vnd.turbo-stream.html" } })
.then(response => response.text())
.then(html => Turbo.renderStreamMessage(html))
}
this.wasDisconnected = false
}
}This Stimulus controller tracks disconnection state and fetches fresh content upon reconnection. The endpoint returns Turbo Stream responses that reconcile any missed updates.
Summary and Next Steps
Turbo Streams eliminate JavaScript complexity while enabling sophisticated real-time features. Key patterns include using model broadcasts for standard CRUD, controller responses for user-specific feedback, and job broadcasts for async operations.
Authorization at both the channel and application level prevents data leaks. Custom actions extend capabilities when built-in actions fall short. Reconnection handling ensures reliability on unstable networks.
For applications already using Turbo Frames, adding Streams unlocks collaborative features—live comments, presence indicators, and real-time dashboards—without architectural changes. Start with a single broadcast on an existing model and expand from there.