Turbo Streams: Updating a Rails Page Without Writing JavaScript
Turbo Streams let Rails push small HTML fragments over Action Cable to update part of a page — no custom JavaScript. A worked comment-thread example, plus the trade-offs and how to verify it.
03 Jun 2026, 10:25 UTC

The problem: a comment thread that feels stale
You have a Rails page showing a post and its comments. When someone adds a comment, other viewers see nothing until they refresh. The classic fix is a JSON API plus client-side rendering code, which means your markup now lives in two places: Rails partials and JavaScript templates. Turbo Streams offer a simpler path: the server sends a small HTML fragment with an instruction like "append this to the element with id comments," and the browser applies it. No custom JavaScript, no duplicated templates.
The thesis of this piece: for common CRUD interfaces, server-broadcast HTML fragments are usually enough, and they keep your partials, helpers, and authorization logic as the single source of truth.
How a Turbo Stream actually works
A Turbo Stream is just an HTML response containing one or more <turbo-stream> elements. Each element names an action — append, prepend, replace, update, or remove — and a target, which is a DOM id. When the browser receives the fragment (either as a form response or over a WebSocket via Action Cable), Turbo finds the target and applies the action.
For real-time updates to other users, the turbo-rails gem adds broadcasting methods on Turbo::StreamsChannel, such as broadcast_append_to and broadcast_replace_to. Clients subscribe with a helper in the view, and the server pushes rendered partials down the cable connection.
Worked example: broadcasting a new comment
Assume Rails 7 with the turbo-rails gem installed and a Post has_many :comments association. Three pieces make it work.
First, subscribe the page to a stream. In app/views/posts/show.html.erb:
<%= turbo_stream_from @post %>
<div id="comments">
<%= render @post.comments %>
</div>The id="comments" wrapper is the stable target. turbo_stream_from @post opens a subscription keyed to that record.
Second, make sure the partial renders one comment with a predictable id. In app/views/comments/_comment.html.erb, wrap the content in <%= turbo_frame_tag comment %> or any element whose id is dom_id(comment) — the broadcasting helpers rely on that convention.
Third, broadcast after create. In the model or controller (run in your Rails app; requires the app's normal server process, no extra permissions):
# app/models/comment.rb
after_create_commit -> { broadcast_append_to post, target: "comments" }Or explicitly from the console for a quick check:
comment = post.comments.create!(body: "hello")
Turbo::StreamsChannel.broadcast_append_to(
post,
target: "comments",
partial: "comments/comment",
locals: { comment: comment }
)Expected result: every browser viewing the post inserts the rendered comment into #comments with no full reload. If nothing appears, open browser dev tools, check the Network tab for the WebSocket connection to /cable, and confirm the incoming message contains a <turbo-stream action="append" target="comments"> payload. A missing or mismatched target id is the most common cause of silent no-ops.
Trade-offs worth knowing before you commit
WebSocket infrastructure is required. Real-time delivery rides on Action Cable, so your deployment must support persistent connections. Proxies, load balancers, and sticky-session settings can behave differently in production than in development — repeat your test behind the real proxy, and confirm your cable adapter (Redis is typical in production) is configured.
Broadcast storms are real. If every small model change fans out to thousands of subscribers, you will feel it. Scope streams narrowly (per-record, as above, rather than one global channel), and move heavy fan-out into background jobs or throttle it.
Authorization still lives on the server. A subscribed client must never receive data it shouldn't see. Render partials with the same scoping you'd use in a normal response, and don't stream private records over a channel name someone else can guess or subscribe to.
Not every interaction fits. Turbo Streams shine for modest DOM updates — lists, badges, flash messages. Highly stateful UI like drag-and-drop editors or collaborative cursors will fight the model; reach for Stimulus or a richer client framework there.
Verify it, then ship it
Behavior around defaults and broadcasting helpers varies across Rails and turbo-rails versions, so check the example against your app's exact versions before copying it into production code. A fast verification loop: create a comment in rails console, watch the DOM update in an open browser session, and confirm in dev tools that the stream action and target match your markup. Then repeat once under production-like cable and proxy settings. If the fragment arrives and the target id matches, you're done — and you got real-time updates for the cost of one callback and one view helper.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.