Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 020 / 100

Turbo Streams for live updates: best practices explained

Push changes to the page.

Expona Admin6 min read

Introduction

Real-time updates have become table stakes for modern web applications. Whether you're building a chat application, a live notification feed, or a collaborative dashboard, users expect changes to appear instantly without page refreshes. Turbo Streams for live updates: best practices explained gives us a framework to push those changes directly to the page, and we'll walk through how to implement them correctly in a production Rails application.

Turbo Streams sit at the intersection of simplicity and power. Instead of polling the server, writing WebSocket handlers, or managing complex client-side state, we can broadcast DOM updates from the server and have them apply across connected clients in real time. In this guide, we'll cover the five core stream actions, how to trigger them from controllers, how models can broadcast changes automatically, and real-world patterns for chat, notifications, and dashboards.

Stream actions: the five primitives

Turbo Streams give us five core actions for modifying the DOM. Each action targets a specific element by its id attribute and transforms it in a predictable way. Understanding when to use each one is fundamental to building responsive features.

Append adds a new element as the last child of a container. This is your go-to action for chat messages, comment threads, or activity feeds where new items arrive at the bottom:

ruby
<turbo-stream action="append" target="messages">
  <template>
    <div id="message-42" class="message">
      <strong>Alice:</strong> Just shipped the feature
    </div>
  </template>
</turbo-stream>

Prepend adds a new element as the first child. Use this for notification banners or feeds where new items should appear at the top:

ruby
<turbo-stream action="prepend" target="notifications">
  <template>
    <div id="notification-789" class="alert alert-info">
      Your order has shipped
    </div>
  </template>
</turbo-stream>

Replace swaps an entire element for new content. This is useful when you need to update a record that's already displayed, such as changing an order status or updating a user's profile card:

ruby
<turbo-stream action="replace" target="order-42">
  <template>
    <div id="order-42" class="order-card">
      <p>Status: <strong>Shipped</strong></p>
      <p>Tracking: ABC123</p>
    </div>
  </template>
</turbo-stream>

Update changes only the inner HTML of a target element, leaving the element itself intact. This is lighter weight than replace and useful for updating counts, status badges, or timestamps:

ruby
<turbo-stream action="update" target="comment-count">
  <template>
    47 comments
  </template>
</turbo-stream>

Remove deletes an element from the DOM. When a user deletes a message or you want to dismiss a notification, remove is the right choice:

ruby
<turbo-stream action="remove" target="message-42" />

A single stream response can contain multiple actions, allowing you to orchestrate complex updates in one request. For example, you might append a new message, update a message count, and prepend a notification all at once.

Triggering streams from controllers

Controllers are the most direct place to send a Turbo Stream response. When an action completes—a message is created, an order status changes—you respond with the turbo_stream format instead of HTML or JSON.

Here's a chat application example. When a user posts a message, the controller broadcasts it to other users in the room and confirms delivery to the sender:

ruby
class MessagesController < ApplicationController
  def create
    @room = Room.find(params[:room_id])
    @message = @room.messages.build(message_params)
    @message.user = current_user

    if @message.save
      respond_to do |format|
        format.turbo_stream do
          render turbo_stream: turbo_stream.append("messages", @message)
        end
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def message_params
    params.require(:message).permit(:body)
  end
end

The key is render turbo_stream:, which accepts one or more stream actions. Notice that we pass the partial or model directly—Rails renders it as a view, automatically wrapping it with the target id="messages".

You can chain multiple actions together:

ruby
respond_to do |format|
  format.turbo_stream do
    render turbo_stream: [
      turbo_stream.append("messages", @message),
      turbo_stream.update("message_count", @room.messages.count),
      turbo_stream.prepend("activity", @message.user.name + " posted")
    ]
  end
end

For error cases, render the form with a 422 status. Turbo will replace the form on the client and display validation errors.

Broadcasting changes from models

Controllers handle immediate responses, but broadcasting keeps all subscribers in sync even when changes happen outside a direct request. When another user updates a record, or a background job completes, broadcasting ensures the update reaches connected clients.

Rails provides broadcast_append_to, broadcast_prepend_to, broadcast_replace_to, broadcast_update_to, and broadcast_remove_to callbacks. These use Action Cable under the hood to send updates to subscribed clients.

Here's a Message model that automatically broadcasts new messages to the room:

ruby
class Message < ApplicationRecord
  belongs_to :room
  belongs_to :user

  after_create_commit -> { broadcast_append_to room }
end

When a message is created and committed, it broadcasts an append action to all subscribers listening on the room stream. The partial rendered is messages/message.html.erb by convention, so make sure that partial exists:

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

For more complex scenarios, use custom blocks:

ruby
class Order < ApplicationRecord
  after_update_commit -> {
    broadcast_replace_to "orders:#{user_id}", partial: "orders/order", locals: { order: self }
  }
end

The broadcast stream name uniquely identifies the set of subscribers. Use scoped names like "room:42" or "user:123:notifications" to ensure updates only reach the relevant clients.

On the view side, clients subscribe with the turbo-cable-stream-source tag:

ruby
<turbo-cable-stream-source channel="RoomChannel" room-id="<%= @room.id %>"></turbo-cable-stream-source>
<div id="messages">
  <%= render @room.messages %>
</div>

This tells Turbo to connect to the RoomChannel and listen for broadcasts. Rails will handle the Action Cable plumbing behind the scenes.

Real-world use cases

Chat applications are the canonical use case. Messages appear instantly, read receipts update without delay, and typing indicators can be sent as custom actions. The pattern is straightforward: append messages as they're created, remove them when deleted, and replace them if edited.

Notification systems benefit enormously from streams. When an event fires—a comment on a user's post, a mention in a message, an order shipped—prepend the notification to the inbox. Users see alerts without navigating away. Combine update actions to mark notifications as read and maintain unread counts.

Live dashboards use update actions to refresh charts, metrics, and tables. A sales dashboard might update conversion rates, revenue totals, and top-performing products as transactions arrive. Broadcasting from background jobs keeps the data fresh without API polling.

Collaborative editing can leverage streams for presence indicators, document updates, and conflict resolution feedback. When one user edits a shared resource, others see the change propagated in real time.

Inventory management uses streams to reflect stock changes across the warehouse. When inventory is allocated, received, or damaged, the counts update live so all users work with current data.

Common mistakes

Forgetting unique IDs is the most common pitfall. Every element you target must have a unique id attribute. Use Rails helpers like dom_id(record) to generate consistent IDs—dom_id(message) produces message_42 if the message has id: 42.

Broadcasting to overly broad streams sends updates to clients that don't need them. Use scoped stream names. Instead of broadcast_append_to "messages", use broadcast_append_to "room:#{room_id}" so only clients in that room receive the update.

Rendering heavy partials repeatedly can overload the server. If you're broadcasting updates for thousands of users, optimize the partial for rendering efficiency or consider pagination and virtual scrolling on the client.

Not handling errors gracefully leaves users confused when an action fails. Always respond with a turbo_stream for both success and failure cases, or use client-side error handling to inform users what went wrong.

Mixing Turbo Streams with page navigation can cause confusion. Streams update the current page; if you need to navigate elsewhere, use a redirect or form submission instead of a stream action.

Key takeaways

  • Use append for new items in feeds, prepend for top-priority updates, replace for entire record changes, update for partial content, and remove for deletions.
  • Respond with turbo_stream format in controllers to immediately push updates to the requesting client.
  • Broadcast from model callbacks using after_commit hooks to sync all connected subscribers when data changes.
  • Use scoped stream names (e.g., "room:42", "user:123:notifications") to target updates precisely and avoid unnecessary network traffic.
  • Always include unique, stable id attributes on elements you'll stream into; use dom_id for consistency.
  • Chat, notifications, dashboards, and collaborative tools are natural fits for Turbo Streams; evaluate other features based on whether real-time updates improve user experience enough to justify the added complexity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles