/ Tags: RAILS / Categories: RAILS

Turbo Streams — Real-Time Page Updates in Rails Without Writing JavaScript

Turbo Streams are the part of Hotwire that makes Rails feel genuinely modern for collaborative, real-time features. Where Turbo Frames handle navigation and scoped updates on the current user’s page, Turbo Streams broadcast changes to multiple connected clients — a message appears for everyone in the chat, a counter updates across all open tabs, a task moves to done on every team member’s board. The remarkable part is that the update logic lives entirely in your Rails models and controllers, expressed in a handful of HTML-over-WebSocket messages.

What Turbo Streams Are


A Turbo Stream is a small HTML fragment preceded by a <turbo-stream> element that tells the browser what to do: append, prepend, replace, update, remove, before, or after. The browser applies the change to the live DOM without a full page reload.

Turbo Streams work in two contexts:

  • HTTP responses — after a form submission, respond with a stream instead of a redirect
  • Action Cable broadcasts — push stream updates to all subscribed clients

Example:

<!-- A Turbo Stream message -->
<turbo-stream action="append" target="messages">
  <template>
    <div id="message_42" class="message">
      <strong>Ada:</strong> Hello, world!
    </div>
  </template>
</turbo-stream>

This single fragment, delivered via HTTP or WebSocket, appends the inner <template> content to the element with id="messages" on every connected client.

HTTP Turbo Streams — After Form Submission


The simplest Turbo Streams use case: respond to a form submission with DOM updates instead of a redirect.

Setup:

# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = current_user.messages.build(message_params)

    if @message.save
      respond_to do |format|
        format.turbo_stream  # renders create.turbo_stream.erb
        format.html { redirect_to messages_path }
      end
    else
      render :new, status: :unprocessable_entity
    end
  end
end

Example:

<!-- app/views/messages/create.turbo_stream.erb -->
<%= turbo_stream.append "messages", partial: "messages/message", locals: { message: @message } %>
<%= turbo_stream.update "message_form", partial: "messages/form", locals: { message: Message.new } %>

Two stream actions in one response: append the new message to the list, then reset the form. The partial messages/message is a standard Rails partial — nothing Turbo-specific about the template itself.

<!-- app/views/messages/_message.html.erb -->
<div id="<%= dom_id(message) %>">
  <strong><%= message.user.name %>:</strong>
  <%= message.content %>
</div>

dom_id(message) generates message_42 from a Message with id 42 — the standard Rails helper for giving records predictable DOM IDs that Turbo can target.

Broadcasting to All Clients — Real-Time Updates


HTTP streams update only the requesting client. For real-time updates across all connected users, use turbo_stream_from and broadcast_* helpers.

Setup:

# Gemfile — already included in Rails 7+ with turbo-rails gem
gem "turbo-rails"
<!-- Subscribe this page to a channel -->
<!-- app/views/messages/index.html.erb -->
<%= turbo_stream_from "messages" %>

<div id="messages">
  <%= render @messages %>
</div>

<%= render "form", message: Message.new %>

Example:

# app/models/message.rb
class Message < ApplicationRecord
  belongs_to :user

  # Broadcast after create — all subscribed clients receive the update
  after_create_commit -> {
    broadcast_append_to "messages",
      partial: "messages/message",
      locals:  { message: self },
      target:  "messages"
  }

  after_destroy_commit -> {
    broadcast_remove_to "messages"
  }

  after_update_commit -> {
    broadcast_replace_to "messages",
      partial: "messages/message",
      locals:  { message: self }
  }
end

Or use the shorthand that registers all three at once:

Example:

class Message < ApplicationRecord
  belongs_to :user

  broadcasts_to -> (message) { "messages" }
end

broadcasts_to sets up after_create_commit, after_update_commit, and after_destroy_commit automatically, using the block to determine the stream name (useful for scoping to a room, project, or user).

Scoped Broadcasts — Per-Room or Per-User


Broadcasting to a global channel updates all users. Scoping broadcasts to a resource keeps updates targeted.

Example:

# app/models/task.rb
class Task < ApplicationRecord
  belongs_to :project

  # Broadcast only to subscribers of this project's stream
  broadcasts_to :project

  # Or with a custom stream name
  broadcasts_to -> (task) { [task.project, "tasks"] }
end
<!-- app/views/projects/show.html.erb -->
<!-- Subscribe only to this project's task updates -->
<%= turbo_stream_from @project, "tasks" %>

<div id="<%= dom_id(@project, :tasks) %>">
  <%= render @project.tasks %>
</div>

User A working on Project 1 sees only Project 1’s task updates. User B on Project 2 sees only Project 2’s. Broadcasting is scoped to the stream name — any string or array of objects you use as the name.

Inline Broadcasts Without Callbacks


When model callbacks are too implicit, broadcast explicitly from a service object or controller.

Example:

# From a controller or service — explicit broadcast
class TasksController < ApplicationController
  def update
    @task = Task.find(params[:id])

    if @task.update(task_params)
      # Explicit broadcast instead of model callback
      Turbo::StreamsChannel.broadcast_replace_to(
        [@task.project, "tasks"],
        target: @task,
        partial: "tasks/task",
        locals:  { task: @task }
      )
      head :ok
    else
      render :edit, status: :unprocessable_entity
    end
  end
end

Example:

# In a background job — broadcast from async context
class TaskCompletionJob < ApplicationJob
  def perform(task_id)
    task = Task.find(task_id)
    task.update!(completed: true, completed_at: Time.current)

    Turbo::StreamsChannel.broadcast_replace_to(
      [task.project, "tasks"],
      target: task,
      partial: "tasks/task",
      locals:  { task: task }
    )
  end
end

Background jobs can broadcast without a connected request — the WebSocket channel delivers to all subscribed clients whenever the job runs.

All Seven Actions


Example:

# In a .turbo_stream.erb template or as broadcast helpers:
turbo_stream.append  "list", partial: "items/item", locals: { item: @item }
turbo_stream.prepend "list", partial: "items/item", locals: { item: @item }
turbo_stream.replace @item,  partial: "items/item", locals: { item: @item }
turbo_stream.update  @item,  partial: "items/item", locals: { item: @item }
turbo_stream.remove  @item
turbo_stream.before  @item,  partial: "items/item", locals: { item: @item }
turbo_stream.after   @item,  partial: "items/item", locals: { item: @item }

replace swaps the entire element (including the outer tag). update replaces only the inner content of the target element. Passing an ActiveRecord object as the target uses dom_id automatically.

Pro-Tip: Broadcasts happen synchronously inside the model callback by default, which means after_create_commit with a broadcast blocks the database transaction from completing until the WebSocket delivery finishes. For high-throughput creates, use broadcast_append_later_to (the _later_ variants) to enqueue the broadcast as a background job instead. This keeps transaction time tight and moves the broadcast latency off the write path — users see the update a fraction of a second later, but your database isn’t waiting on WebSocket delivery.

Conclusion


Turbo Streams deliver real-time collaborative features — live feeds, presence indicators, task boards, notification counts — without a dedicated JavaScript frontend or a REST-over-WebSocket API layer. The update logic stays in your Rails models and controllers, expressed in partials and a handful of broadcast helpers. For most applications that need live updates without a full SPA, Turbo Streams cover the requirement cleanly and leave the architecture recognizably Rails.

FAQs


Q1: Does Turbo Streams require Action Cable?
Broadcasting to multiple clients requires Action Cable (or a compatible adapter). HTTP Turbo Streams — responding to a form submit with a stream — work without Action Cable. Most real-time use cases need Action Cable.

Q2: How do I authenticate WebSocket connections?
Action Cable uses your Rails session for authentication. The turbo_stream_from helper signs the stream name with the application secret, preventing unauthorized subscription. For per-user streams, use turbo_stream_from current_user — the signed stream name ensures only the authenticated user receives those updates.

Q3: Can I use Turbo Streams outside of a browser?
Turbo Streams are designed for browser clients using the Turbo JavaScript library. For native mobile apps, you’d use the Turbo Native adapters (iOS/Android) or a separate API.

Q4: What’s the difference between broadcast_replace_to and broadcast_update_to?
broadcast_replace_to replaces the entire target element including the outer tag. broadcast_update_to replaces only the inner content. Use replace when the element’s ID or attributes might change; use update when you only need to refresh the inner HTML.

Q5: How do I handle Turbo Streams for unauthenticated users?
Stream channels can be public (no authentication) or authenticated. For public streams, skip the signed verification. For authenticated streams, use turbo_stream_from only in views rendered for authenticated users, and verify authentication in your Action Cable connection class.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More