Turbo Streams — Real-Time Page Updates in Rails Without Writing JavaScript
Turbo Streams are the part of Hotwire that makes Rails feel genuinely modern for collaborative, real-time features. Where Turbo Frames handle navigation and scoped updates on the current user’s page, Turbo Streams broadcast changes to multiple connected clients — a message appears for everyone in the chat, a counter updates across all open tabs, a task moves to done on every team member’s board. The remarkable part is that the update logic lives entirely in your Rails models and controllers, expressed in a handful of HTML-over-WebSocket messages.
What Turbo Streams Are
A Turbo Stream is a small HTML fragment preceded by a <turbo-stream> element that tells the browser what to do: append, prepend, replace, update, remove, before, or after. The browser applies the change to the live DOM without a full page reload.
Turbo Streams work in two contexts:
- HTTP responses — after a form submission, respond with a stream instead of a redirect
- Action Cable broadcasts — push stream updates to all subscribed clients
Example:
<!-- A Turbo Stream message -->
<turbo-stream action="append" target="messages">
<template>
<div id="message_42" class="message">
<strong>Ada:</strong> Hello, world!
</div>
</template>
</turbo-stream>
This single fragment, delivered via HTTP or WebSocket, appends the inner <template> content to the element with id="messages" on every connected client.
HTTP Turbo Streams — After Form Submission
The simplest Turbo Streams use case: respond to a form submission with DOM updates instead of a redirect.
Setup:
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def create
@message = current_user.messages.build(message_params)
if @message.save
respond_to do |format|
format.turbo_stream # renders create.turbo_stream.erb
format.html { redirect_to messages_path }
end
else
render :new, status: :unprocessable_entity
end
end
end
Example:
<!-- app/views/messages/create.turbo_stream.erb -->
<%= turbo_stream.append "messages", partial: "messages/message", locals: { message: @message } %>
<%= turbo_stream.update "message_form", partial: "messages/form", locals: { message: Message.new } %>
Two stream actions in one response: append the new message to the list, then reset the form. The partial messages/message is a standard Rails partial — nothing Turbo-specific about the template itself.
<!-- app/views/messages/_message.html.erb -->
<div id="<%= dom_id(message) %>">
<strong><%= message.user.name %>:</strong>
<%= message.content %>
</div>
dom_id(message) generates message_42 from a Message with id 42 — the standard Rails helper for giving records predictable DOM IDs that Turbo can target.
Broadcasting to All Clients — Real-Time Updates
HTTP streams update only the requesting client. For real-time updates across all connected users, use turbo_stream_from and broadcast_* helpers.
Setup:
# Gemfile — already included in Rails 7+ with turbo-rails gem
gem "turbo-rails"
<!-- Subscribe this page to a channel -->
<!-- app/views/messages/index.html.erb -->
<%= turbo_stream_from "messages" %>
<div id="messages">
<%= render @messages %>
</div>
<%= render "form", message: Message.new %>
Example:
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :user
# Broadcast after create — all subscribed clients receive the update
after_create_commit -> {
broadcast_append_to "messages",
partial: "messages/message",
locals: { message: self },
target: "messages"
}
after_destroy_commit -> {
broadcast_remove_to "messages"
}
after_update_commit -> {
broadcast_replace_to "messages",
partial: "messages/message",
locals: { message: self }
}
end
Or use the shorthand that registers all three at once:
Example:
class Message < ApplicationRecord
belongs_to :user
broadcasts_to -> (message) { "messages" }
end
broadcasts_to sets up after_create_commit, after_update_commit, and after_destroy_commit automatically, using the block to determine the stream name (useful for scoping to a room, project, or user).
Scoped Broadcasts — Per-Room or Per-User
Broadcasting to a global channel updates all users. Scoping broadcasts to a resource keeps updates targeted.
Example:
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
# Broadcast only to subscribers of this project's stream
broadcasts_to :project
# Or with a custom stream name
broadcasts_to -> (task) { [task.project, "tasks"] }
end
<!-- app/views/projects/show.html.erb -->
<!-- Subscribe only to this project's task updates -->
<%= turbo_stream_from @project, "tasks" %>
<div id="<%= dom_id(@project, :tasks) %>">
<%= render @project.tasks %>
</div>
User A working on Project 1 sees only Project 1’s task updates. User B on Project 2 sees only Project 2’s. Broadcasting is scoped to the stream name — any string or array of objects you use as the name.
Inline Broadcasts Without Callbacks
When model callbacks are too implicit, broadcast explicitly from a service object or controller.
Example:
# From a controller or service — explicit broadcast
class TasksController < ApplicationController
def update
@task = Task.find(params[:id])
if @task.update(task_params)
# Explicit broadcast instead of model callback
Turbo::StreamsChannel.broadcast_replace_to(
[@task.project, "tasks"],
target: @task,
partial: "tasks/task",
locals: { task: @task }
)
head :ok
else
render :edit, status: :unprocessable_entity
end
end
end
Example:
# In a background job — broadcast from async context
class TaskCompletionJob < ApplicationJob
def perform(task_id)
task = Task.find(task_id)
task.update!(completed: true, completed_at: Time.current)
Turbo::StreamsChannel.broadcast_replace_to(
[task.project, "tasks"],
target: task,
partial: "tasks/task",
locals: { task: task }
)
end
end
Background jobs can broadcast without a connected request — the WebSocket channel delivers to all subscribed clients whenever the job runs.
All Seven Actions
Example:
# In a .turbo_stream.erb template or as broadcast helpers:
turbo_stream.append "list", partial: "items/item", locals: { item: @item }
turbo_stream.prepend "list", partial: "items/item", locals: { item: @item }
turbo_stream.replace @item, partial: "items/item", locals: { item: @item }
turbo_stream.update @item, partial: "items/item", locals: { item: @item }
turbo_stream.remove @item
turbo_stream.before @item, partial: "items/item", locals: { item: @item }
turbo_stream.after @item, partial: "items/item", locals: { item: @item }
replace swaps the entire element (including the outer tag). update replaces only the inner content of the target element. Passing an ActiveRecord object as the target uses dom_id automatically.
Pro-Tip: Broadcasts happen synchronously inside the model callback by default, which means
after_create_commitwith a broadcast blocks the database transaction from completing until the WebSocket delivery finishes. For high-throughput creates, usebroadcast_append_later_to(the_later_variants) to enqueue the broadcast as a background job instead. This keeps transaction time tight and moves the broadcast latency off the write path — users see the update a fraction of a second later, but your database isn’t waiting on WebSocket delivery.
Conclusion
Turbo Streams deliver real-time collaborative features — live feeds, presence indicators, task boards, notification counts — without a dedicated JavaScript frontend or a REST-over-WebSocket API layer. The update logic stays in your Rails models and controllers, expressed in partials and a handful of broadcast helpers. For most applications that need live updates without a full SPA, Turbo Streams cover the requirement cleanly and leave the architecture recognizably Rails.
FAQs
Q1: Does Turbo Streams require Action Cable?
Broadcasting to multiple clients requires Action Cable (or a compatible adapter). HTTP Turbo Streams — responding to a form submit with a stream — work without Action Cable. Most real-time use cases need Action Cable.
Q2: How do I authenticate WebSocket connections?
Action Cable uses your Rails session for authentication. The turbo_stream_from helper signs the stream name with the application secret, preventing unauthorized subscription. For per-user streams, use turbo_stream_from current_user — the signed stream name ensures only the authenticated user receives those updates.
Q3: Can I use Turbo Streams outside of a browser?
Turbo Streams are designed for browser clients using the Turbo JavaScript library. For native mobile apps, you’d use the Turbo Native adapters (iOS/Android) or a separate API.
Q4: What’s the difference between broadcast_replace_to and broadcast_update_to?
broadcast_replace_to replaces the entire target element including the outer tag. broadcast_update_to replaces only the inner content. Use replace when the element’s ID or attributes might change; use update when you only need to refresh the inner HTML.
Q5: How do I handle Turbo Streams for unauthenticated users?
Stream channels can be public (no authentication) or authenticated. For public streams, skip the signed verification. For authenticated streams, use turbo_stream_from only in views rendered for authenticated users, and verify authentication in your Action Cable connection class.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀