Broadcasting with Turbo Streams — making everyone’s browser light up at once
Part 2 of the article series written by o3 to teach me Hotwire concepts
Turbo Stream Broadcasting Guide
A companion to your Turbo Streams 101 write‑up — this piece focuses only on the push side of things: getting the server to light up every subscriber’s browser in real time.
0. The 30‑second teaser
Responding = pull → browser asks, server answers (HTML, JSON, whatever).
Broadcasting = push → server decides “something changed” and pushes an HTML diff to all interested browsers via WebSockets.
No refreshing, no polling, no extra JS — Hotwire wraps it all for you.
1. “What’s a WebSocket, anyway?”
Plain English: A WebSocket is a pipe that stays open. Instead of the browser and server playing “you first / no, you first” (HTTP request → response → new request), they establish one long‑lived TCP connection and both can speak whenever they like.
Analogy: Think of HTTP as sending letters; a WebSocket is opening a phone line.
Hard part (that seniors gripe about): keeping that phone line alive across proxies, load balancers, and scale‑out servers.
Rails’ gift:
ActionCable
. It hides the plumbing and gives you a pub/sub API. Turbo Rails then adds a cherry on top: it turns every pub/sub message into an easy‑to‑digest<turbo-stream>
envelope so you never serialize JSON by hand.
In short, yes — Rails runs the WebSocket server for you (via Puma threads), and you mostly forget it’s there.
2. Big‑picture mental model (pull vs push)
📄 View: <%= turbo_stream_from @chatroom %>
↕ ← opens → 📞 WebSocket channel "chatroom_7"
💾 Model save (Message#create)
↓ triggers 🚀 broadcast_append_to "chatroom_7"
<turbo-stream action="append" target="messages_7">…html…</turbo-stream>
↓
🖥️ Browser JS receives frame, Turbo inserts <li> into the list
The request/response cycle never fires — the server simply pushes a DOM change.
3. Building blocks in everyday English
No matter which helper you call, the payload is always HTML, never JSON.
4. Three ways to broadcast — and when to choose each
Quick heuristic: 90 % of the time start with broadcasts_to
— graduate to manual only when lifecycle or payload deviates from CRUD.
5. Example 1 — live chat in four lines of code
Model
class Message < ApplicationRecord
belongs_to :room
broadcasts_to :room, inserts_by: :append,
target: ->(msg) { "messages_#{msg.room.id}" }
end
View subscription
<%= turbo_stream_from @room %>
<ul id="messages_<%= @room.id %>">
<%= render @room.messages %>
</ul>
Timeline in words
Browser A submits the form → standard controller response updates its own DOM.
After
Message
saves, the model callback broadcasts anappend
stream to channelroom_7
.Browser B (already subscribed) receives the
<turbo-stream>
frame and inserts the new<li>
.
Zero hand‑written JavaScript.
6. Example 2 — background job progress bar (manual broadcast)
class ImportJob < ApplicationJob
def perform(import)
total = import.csv.count
processed = 0
import.csv.each do |row|
ImportRow.create!(row: row, import: import)
processed += 1
percent = (processed.to_f / total * 100).round
Turbo::StreamsChannel.broadcast_replace_to "import_#{import.id}",
target: "progress_bar_#{import.id}",
partial: "imports/progress",
locals: { percent: percent }
end
end
end
Why manual? No model save corresponds one‑to‑one with “update progress bar”; we decide the cadence.
Loosely coupled: Web UI stays responsive while job churns in Sidekiq.
View side:
<%= turbo_stream_from "import_#{@import.id}" %>
<div id="progress_bar_<%= @import.id %>">
<%= render "imports/progress", percent: 0 %>
</div>
7. Private / namespaced channels explained
A channel name is just a string. Make it descriptive and safe:
<%= turbo_stream_from [current_user, :inbox] %> ➜ "user_42_inbox" (only they can see their DMs)
Rails encodes the composite into a channel name under the hood. Because subscription code runs in the view, you can gate it behind if current_user.admin?
or similar — keeping secrets secret.
8. When not to broadcast (expanded)
Broadcasting is wonderful — but like any megaphone, shouting all the time clogs the airwaves.
9. Why do we suddenly care about performance? (and where Redis fits)
WebSockets are stateful. Each open socket consumes memory and threads. Ten users? Fine. Ten thousand? You need orchestration.
Redis = the pub/sub switchboard. By default Action Cable points every app server at Redis. When one server publishes a message, Redis fans it out to subscribers connected to any server, keeping everyone in sync.
Scaling path
Dev / small prod – in‑process adapter (no Redis) is okay for a few hundred connections.
Standard prod – Redis adapter ➜ tens of thousands of sockets.
Very large – AnyCable (GRPC + Go) or Phoenix PubSub ➜ hundreds of thousands.
Cost hotspots
Rendering partials for each broadcast (CPU) → mitigate with fragment caching.
Network frames per second (bandwidth) → batch or debounce.
Redis fan‑out (memory) → shard or use a dedicated Redis cluster.
Rule of thumb: Start simple with Redis. Benchmark before you panic — HTML diffs are tiny, and Hotwire sites often surprise you by scaling further than expected.
10. Testing & debugging in human terms
Browser dev tools → Network → WS → Frames — you should see
<turbo-stream>
snippets arriving.Rails logs → set
config.action_cable.log_tags = [ :action_cable ]
in development for verbose channel chatter.Rails tests
assert_broadcast_on("room_7", /<turbo-stream action=\"append\"/)
curl
trick
curl -H "Accept: text/vnd.turbo-stream.html" \
-d "body=Hello" http://localhost:3000/messages
– see the raw stream the browser would get.
11. Quick‑start checklist (copy/paste)
# 1. Model
class Comment < ApplicationRecord
belongs_to :post
broadcasts_to :post, inserts_by: :prepend
end
# 2. View
<%= turbo_stream_from @post %>
<ul id="comments_<%= @post.id %>">
<%= render @post.comments %>
</ul>
# 3. Done – multi‑user live comments in two lines.
One‑sentence takeaway
Broadcasting turns Turbo Streams from “my action updated my page” into “any change anywhere updates everyone’s page instantly,” powered by WebSockets that Rails hides behind cozy helpers — Redis just makes sure all your app servers stay in sync.