Post

Understanding broadcasts_to in Ruby on Rails: A Deep Technical Walkthrough

Real-time user interfaces used to require a significant amount of JavaScript, client-side logic, or frontend frameworks. With Rails, Hotwire, and Turbo Streams, we can update browser views instantly with almost no JavaScript. One of the mechanisms behind this capability is the broadcasts_to macro.

This article explains how broadcasts_to works internally, step by step. Once the underlying mechanism is clear, we’ll see why Rails needs only a single line of model code to drive full real-time behavior.

What broadcasts_to Actually Does

broadcasts_to is a macro provided by Turbo’s model integration. When placed inside an ActiveRecord model, it instructs Rails to automatically broadcast Turbo Stream updates whenever that record is created, updated, or destroyed.

Example macro:

1
2
3
class Comment < ApplicationRecord
  broadcasts_to ->(comment) { "posts_#{comment.post_id}_comments" }
end

This tells Rails:

  • Each time a Comment is created, updated, or deleted,
  • Generate a Turbo Stream HTML fragment,
  • And push it to the stream named "posts_#{comment.post_id}_comments".

From the developer’s point of view, that is all that’s required. Under the hood, the system is considerably more elaborate.

Lifecycle Integration: Triggering at Exactly the Right Time

broadcasts_to attaches itself to specific ActiveRecord callbacks:

  • after_create_commit
  • after_update_commit
  • after_destroy_commit

The key point is the commit part. Broadcasting only happens after the database transaction has successfully committed. This protects the client from receiving signals about records that failed to persist. Once the commit completes, Turbo’s broadcast layer is notified, and the rendering process begins.

How Turbo Builds the Broadcast Message

When the broadcast fires, Rails constructs a Turbo Stream document. This is HTML that describes a specific DOM operation, such as:

  • append,
  • prepend,
  • replace,
  • remove.

A typical generated Turbo Stream looks like:

1
2
3
4
5
<turbo-stream action="append" target="comments">
  <template>
    <div id="comment_12">...</div>
  </template>
</turbo-stream>

The <template> contains the usual partial for the model. Turbo uses a tag builder under the hood (Turbo::Streams::TagBuilder) to wrap the rendered partial within the appropriate Turbo Stream action. This HTML fragment becomes the payload that the server will send to all subscribed browsers.

Transport Layer: Broadcasting via ActionCable

Once the Turbo Stream HTML is ready, Rails delivers it using ActionCable, Rails’ WebSocket framework. Internally, this is equivalent to executing:

1
ActionCable.server.broadcast(stream_name, html_payload)

Where stream_name is defined by the lambda inside broadcasts_to. If the application is running with multiple app servers, ActionCable’s pub/sub adapter (commonly Redis) ensures that every server sends the broadcast to its connected clients. This guarantees consistent real-time behavior across distributed systems.

Client Subscription: How Browsers Receive the Data

A browser only receives broadcasts if a view explicitly subscribes to the corresponding stream. This is done using:

1
<%= turbo_stream_from "posts_#{@post.id}_comments" %>

Rails renders a hidden <turbo-cable-stream-source> element, which instructs Turbo to:

  • Open a WebSocket connection to /cable.
  • Subscribe to Turbo::StreamsChannel.
  • Provide a signed stream name. The signature prevents clients from subscribing to arbitrary streams.

When ActionCable broadcasts a message to that stream, all connected subscribers immediately receive the packaged Turbo Stream HTML.

Applying the Updates: The Browser’s Final Step

On the client side, the Turbo framework parses the incoming <turbo-stream> HTML. It reads:

  • the action (append, replace, remove)
  • the target element
  • the template contents

Turbo then performs the appropriate DOM manipulation. For example:

  • append → insert HTML at the end of the target container
  • replace → replace a specific element with the new one
  • remove → delete the element entirely

No manual JavaScript is required, no page reloads and the update is immediate.

This completes the loop: model change → commit → render → broadcast → DOM update.

Example: Post and Comment

Below is a complete, minimal example showing how everything ties together.

Models

  • post.rb
1
2
3
4
class Post < ApplicationRecord
  has_many :comments, dependent: :destroy
  broadcasts_to ->(post) { "posts_#{post.id}_comments" }
end
  • comment.rb
1
2
3
4
class Comment < ApplicationRecord
  belongs_to :post
  broadcasts_to ->(comment) { "posts_#{comment.post_id}_comments" }
end

Each comment now automatically broadcasts to its post’s comment stream.

Views

  • posts/show.html.erb
1
2
3
4
5
6
<%= turbo_stream_from "posts_#{@post.id}_comments" %>

<h2>Comments</h2>
<div id="comments">
  <%= render @post.comments %>
</div>

Partial

  • _comment.html.erb
1
2
3
4
<div id="<%= dom_id(comment) %>">
  <strong><%= comment.author %></strong>
  <p><%= comment.body %></p>
</div>

Once the user creates a comment, Rails automatically:

  • Fires after_create_commit
  • Generates a Turbo Stream “append”
  • Broadcasts it to the WebSocket
  • Browser inserts the new comment into the DOM instantly No JS, no custom channels, no frontend code.
This post is licensed under CC BY 4.0 by the author.

Comments powered by Disqus.