Real-Time UI Updates with Turbo Streams in Rails 7 and 8
Real-time features used to mean reaching for ActionCable, writing JavaScript event listeners, and managing WebSocket state that lives entirely outside your Rails stack. Turbo Streams changed that equation entirely. Built into Rails 7 and fully matured in Rails 8, Turbo Streams let you broadcast targeted DOM updates from the server with almost no client-side code — and the patterns are simpler than most Rails developers realise when they first encounter them.
What Turbo Streams Actually Do
Turbo Streams deliver HTML fragments tagged with an action — append, prepend, replace, update, remove — directly to specific DOM elements. The browser receives a <turbo-stream> element, applies the action, and moves on. No page reload, no JavaScript event listeners, no DOM diffing algorithm to wrestle with.
The server sends this over the wire:
<turbo-stream action="append" target="messages">
<template>
<div id="message_42">Hello from the server</div>
</template>
</turbo-stream>
Turbo intercepts it and appends the template content to #messages. That’s the entire client-side story.
Broadcasting from Models
The most common pattern is broadcasting immediately after a record changes.
Setup:
class Message < ApplicationRecord
belongs_to :room
after_create_commit { broadcast_append_to room }
after_update_commit { broadcast_replace_to room }
after_destroy_commit { broadcast_remove_to room }
end
View subscription (rooms/show.html.erb):
<%= turbo_stream_from @room %>
<div id="messages">
<%= render @room.messages %>
</div>
When any connected client creates a Message, all subscribers see it appended in real time. No controller action required. Zero JavaScript written. In my experience, this single pattern handles 80% of real-time UI requirements teams throw at it.
The Seven Turbo Stream Actions
| Action | What it does |
|---|---|
append |
Adds content inside target, after existing children |
prepend |
Adds content inside target, before existing children |
replace |
Swaps the target element itself |
update |
Replaces content inside the target, preserving the element |
remove |
Deletes the target element |
before |
Inserts content before the target element |
after |
Inserts content after the target element |
Choosing the wrong action is the most common source of Turbo confusion. Use replace when the target element itself should change; use update when only its inner content changes and the wrapping element stays in place.
Controller-Driven Streams
Sometimes you need finer control — streaming after a form submission with conditional logic, for example.
Controller:
def create
@message = current_room.messages.build(message_params)
respond_to do |format|
if @message.save
format.turbo_stream
format.html { redirect_to current_room }
else
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"message_form",
partial: "messages/form",
locals: { message: @message }
)
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
create.turbo_stream.erb:
<%= turbo_stream.append "messages", @message %>
<%= turbo_stream.replace "message_form", partial: "messages/form", locals: { message: Message.new } %>
Two streams, one response: append the new message and reset the form to a clean state. This is the pattern to reach for whenever a single action has multiple downstream UI consequences.
Scoping Streams for Multi-Tenant Systems
The default broadcast_append_to targets based on the record. For multi-tenant apps or permission-gated content, you need named streams with tighter scoping.
Model:
class Message < ApplicationRecord
belongs_to :room
after_create_commit do
broadcast_append_to "room_#{room_id}_org_#{room.organisation_id}"
end
end
View:
<%= turbo_stream_from "room_#{@room.id}_org_#{current_user.organisation_id}" %>
This prevents users from subscribing to streams they shouldn’t see — a critical detail teams consistently miss when first adopting Turbo Streams.
Pro-Tip: Always namespace your stream identifiers by tenant or permission scope. A stream named only by record ID is a side-channel information leak in any multi-tenant system. An attacker who knows the stream naming convention can subscribe to records they don’t own.
Rails 8: Solid Cable Removes the Redis Dependency
Rails 8 ships with Solid Cable, a database-backed ActionCable adapter. For teams not already running Redis, this eliminates one infrastructure dependency entirely.
config/cable.yml:
production:
adapter: solid_cable
polling_interval: 0.1.seconds
message_retention: 1.day
Solid Cable stores broadcast messages in your existing database and polls at a configurable interval. For most applications with moderate real-time traffic, this performs excellently and dramatically simplifies deployment.
Testing Turbo Stream Responses
RSpec request spec:
RSpec.describe "POST /rooms/:room_id/messages", type: :request do
let(:room) { create(:room) }
it "broadcasts the new message to the room stream" do
expect {
post room_messages_path(room),
params: { message: { body: "Hello" } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
}.to have_broadcasted_to(room).with(a_string_including("Hello"))
expect(response.media_type).to eq "text/vnd.turbo-stream.html"
end
it "returns turbo stream with validation errors on invalid input" do
post room_messages_path(room),
params: { message: { body: "" } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response.body).to include("turbo-stream")
expect(response.body).to include("message_form")
end
end
Conclusion
Turbo Streams deliver real-time interactivity that used to require dedicated WebSocket infrastructure and hundreds of lines of JavaScript — in a handful of ERB lines and a single model callback. The architecture is server-centric, which means your Rails business logic stays in one place, your views stay simple, and your mental model doesn’t fork between server and client. Start with model callbacks for the common case, reach for controller streams when conditional logic is involved, scope your stream identifiers tightly from day one, and consider Solid Cable if you’d rather keep Redis out of your stack entirely.
FAQs
Q1: Do Turbo Streams require ActionCable for all use cases?
Broadcasting to multiple clients requires ActionCable. For single-client stream responses from form submissions, no WebSocket connection is needed — the stream returns in the HTTP response body.
Q2: Can I use Turbo Streams in API-only Rails apps?
No. Turbo Streams are HTML-over-the-wire and require a Turbo-enabled frontend. API-only apps should use JSON WebSocket channels instead.
Q3: What’s the difference between broadcast_append_to and broadcast_replace_to?
broadcast_append_to adds the record as a new DOM element inside the target. broadcast_replace_to swaps an existing element with the same ID — use it for updates to existing records, not creation.
Q4: How do I handle Turbo Stream errors in forms?
Return a turbo_stream format in your error branch and use turbo_stream.replace to swap the form partial with validation errors rendered inline. Always return status: :unprocessable_entity for the HTML format fallback.
Q5: Does Solid Cable in Rails 8 support high-traffic real-time apps?
Solid Cable performs well for moderate traffic. For applications with thousands of concurrent connections and sub-100ms latency requirements, Redis-backed ActionCable remains the stronger choice.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀