Back to Course

Hotwire Series · 1 of 6

Turbo Streams vs Broadcasts

Where Hotwire's two transport channels come from, why most teams reach for ActionCable when they should be streaming inline, and the render-and-broadcast pattern that keeps the two from stepping on each other.

Where this rule comes from

Hotwire shipped with Rails 7 in 2021. The framework moves HTML between the server and the browser through two transport channels, both producing the same wire format (Turbo Stream markup), and both consumed by the same Turbo client running on the page.

The first channel is the HTTP response. When a form is submitted, the controller renders a turbo_stream action and the response carries the stream markup back to the same browser that made the request. Standard request/response cycle, no WebSocket involved.

The second channel is ActionCable. A model after_commit callback can broadcast Turbo Stream markup to a named subscription. Any browser holding an open WebSocket to that subscription receives the markup and Turbo applies it.

These two channels were designed to share one partial. The HTML for "a new comment appearing under a post" lives in one ERB file. Both channels render it. The Turbo team called this the render-and-broadcast pattern in the original release notes.

The problem most teams hit in month one is treating these channels as alternatives instead of as complementary. Either everything goes through ActionCable (and the broker pays for work it should not), or everything is rendered inline (and a second browser watching the same state never sees the update). The senior move is knowing which channel each piece of behavior belongs in.

The anti-pattern: broadcast everything

A team learns about broadcasts_to and reaches for it on every model. The Post model gains:

class Post < ApplicationRecord
  belongs_to :user
  has_many :comments

  broadcasts_to ->(post) { [post.user, :feed] }, inserts_by: :prepend
end

The intent reads as "when a post is created, push it to the user's feed." The actual sequence of events:

  1. The user creates the post via POST /posts. The controller redirects to /feed.
  2. The after_commit runs. ActionCable serializes the markup and pushes it to every subscriber of [user, :feed].
  3. The browser receives the redirect, navigates to /feed, and the page renders with the new post already in the database.
  4. A moment later, ActionCable delivers the broadcast. The browser receives a stream that tries to prepend a post that is already on the page.

Depending on timing, the user sees the post twice, watches it flicker, or never notices the broadcast at all because the database read got there first. The request paid for an extra render and an extra ActionCable round-trip the originating browser did not need.

The cost compounds with subscribers. If a user has the page open in five tabs, ActionCable fans the broadcast out to all five connections, and each browser applies the prepend. Multiplied across a 10,000-user site, the broker does real work for updates the originating browser already had via HTTP.

The team's first instinct, when the duplicate render shows up in QA, is to skip the broadcast for the current actor with an if Current.user != post.user check. That fixes the visible bug. Every other subscriber of [user, :feed] still receives a push that, for most apps, has no real audience. Does the user's feed have viewers other than the user? Often, no.

The render-and-broadcast pattern

The senior shape splits the two channels by purpose. CRUD responses use inline streams. Cross-user notifications use broadcasts. Same partial powers both.

class PostsController < ApplicationController
  def create
    @post = current_user.posts.create!(post_params)
    respond_to do |format|
      format.turbo_stream # renders create.turbo_stream.erb
      format.html         { redirect_to feed_path }
    end
  end
end
<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.prepend "feed", partial: "posts/post", locals: { post: @post } %>
<%= turbo_stream.replace "new_post_form", partial: "posts/form", locals: { post: Post.new } %>

The controller renders the stream inline. The browser that submitted the form receives the markup in the same HTTP response. The page updates without a navigation, the form resets, and the user sees their post in the same round-trip they were already paying for.

Broadcasts are reserved for browsers that did not trigger the change: a second user watching the same thread, a teammate's admin queue, a public status panel. The shape:

class Comment < ApplicationRecord
  belongs_to :post

  after_create_commit -> {
    broadcast_append_later_to(
      [post, :comments],
      target:  dom_id(post, :comments),
      partial: "comments/comment",
      locals:  { comment: self }
    )
  }
end

Two choices are doing the work here. after_create_commit runs after the transaction commits, so a rolled-back create never broadcasts. broadcast_append_later_to queues the render through Active Job, so the request thread does not block while rendering markup for N subscribers.

The author of the comment sees their own comment via the controller's respond_to stream. Everyone else on the post sees it via the broadcast. The same partial powers both channels, so when the partial changes, both reflect the change on the next deploy.

When to broadcast, when to stream inline

The decision rule is one sentence. Broadcasts are for browsers that did not make the request. If only the originating browser needs the update, render the stream inline. If other browsers need to see it (because they are watching shared state), broadcast.

Concrete cases from a working Rails app:

  • A user edits their own profile name. Inline stream. No second observer needs to react.
  • A user publishes a blog post. Inline stream for their own dashboard, plus a broadcast to subscribers of the public feed.
  • An admin approves a flagged comment. Inline stream for the admin queue, plus a broadcast to the public comment thread.
  • A background job classifies a new email. Broadcast only. There is no user request to attach a stream to.
  • A long export finishes in Sidekiq. Broadcast only. Same reason.

The trap is the case that looks like it needs a broadcast but does not. A single-user feed where only the owner ever logs in needs no broadcast; an inline stream is enough. A status page that updates every minute via a background poller does need one (no user request triggered the change).

The N+1 hiding in broadcasts

The cost model is non-obvious. Each broadcast_*_to call performs three operations: render the partial server-side, serialize the resulting HTML, and push it through ActionCable for the broker to fan out to every active subscription matching the stream name.

The fan-out itself is cheap. The render is not. If one Post create triggers ten broadcasts (one per tagged subscriber, one per follower of the author, one per feed type), the request pays for ten partial renders before responding.

broadcast_*_later_to moves the render into Active Job. The request returns immediately and the worker pool pays the cost. That is the right shape when the render is non-trivial, or when fan-out is large enough that doing N renders inline would stretch the response over a hundred milliseconds.

The other N+1 sneaks in through after_commit on bulk updates. If a service updates 500 Posts in a loop and each fires a broadcast, 500 jobs hit the queue at once. Wrap bulk operations in a flag that suppresses the callback (a thread-local switch around Post.skip_broadcasts do ... end is the canonical pattern) so the bulk path bypasses per-record work.

The principle at play

Hotwire's design assumes the two channels run together, not as alternatives. The CRUD response is the user's own feedback loop, fast and low-latency because it rides the HTTP request the user already made. The broadcast is for everyone else, asynchronous because it has to traverse the WebSocket and re-render the partial server-side.

When you find yourself reaching for a broadcast where an inline stream would do, the question to ask is whether a second observer of this state actually exists. If the answer is no, broadcasts add complexity (subscription management, render cost, ordering questions between the HTTP response and the WebSocket message) without buying anything. If the answer is yes, broadcasts are exactly the right tool and an inline stream is not a substitute.

The pattern shows up across the apps the Hotwire team itself ships. HEY broadcasts to a recipient's inbox when the email classifier updates a thread. Basecamp broadcasts chat messages to every reader of the thread. The actor's own browser, in both cases, gets the update through the HTTP response that triggered it. Same partial, two channels, no overlap.

Practice exercise

  1. Open a Rails app you know that uses Hotwire. Grep for broadcasts_to and broadcast_*_to.
  2. For each call, identify the audience. Is it "the actor's other tabs" or "other users entirely"? If the answer is the former, the broadcast can be replaced with an inline stream and a controller respond_to.
  3. Find a controller action that currently redirects after a create!. Add a format.turbo_stream handler and a matching *.turbo_stream.erb. Confirm the page updates without a full navigation.
  4. Find a model with a high write rate (audit logs, view counts, soft-delete tombstones) that has a broadcasts_to. Compare its write volume against the read volume of the broadcast destination. If reads are near zero, remove the broadcast.

Related lessons