Turbo Streams: Real-Time Updates Without Writing JavaScript

Turbo Drive speeds up full-page navigation. Turbo Frames let you update specific regions in response to user actions. But what about updates that happen without user action?

A new message arrives while you're viewing a chat. Someone comments on a post you're reading. A notification pops up. Your dashboard updates with live data.

In traditional SPAs, you'd set up WebSockets, write client-side state management, and handle synchronization between client and server. It's a lot of work.

Turbo Streams makes this simple: your server sends HTML fragments describing what changed, and Turbo applies them automatically. No JSON parsing, no client-side rendering, no state management.

What Turbo Streams Do


Turbo Streams let you deliver page changes from the server using nine simple actions. Each action is a verb that describes a DOM operation:

1. append — Add content to the end of a target element
2. prepend — Add content to the beginning
3. replace — Replace an entire element
4. update — Replace an element's innerHTML
5. remove — Delete an element
6. before — Insert content before an element
7. after — Insert content after an element
8. morph — Smart-update preserving state
9. refresh — Trigger a page refresh

These nine actions cover almost everything you need for dynamic UIs.

The Basic Structure


Every Turbo Stream message follows the same pattern:

<turbo-stream action="append" target="messages">
  <template>
    <div id="message-42">New message content</div>
  </template>
</turbo-stream>

The action says what to do. The target says which element to do it to (by ID). The <template> contains the HTML to insert.

When Turbo sees this, it finds the element with id="messages" and appends the template content to it. That's it.

The Nine Actions


Append — Add to the end:

<turbo-stream action="append" target="messages">
  <template>
    <div id="message-42">New message!</div>
  </template>
</turbo-stream>

Use for: chat messages, activity feeds, infinite scroll.

Prepend — Add to the beginning:

<turbo-stream action="prepend" target="notifications">
  <template>
    <div class="notification">You have mail!</div>
  </template>
</turbo-stream>

Use for: notifications (newest at top), news feeds, reverse-chronological lists.

Replace — Swap entire element (including the element itself):

<turbo-stream action="replace" target="order-status">
  <template>
    <div id="order-status" class="success">
      Order shipped! Tracking: #123456
    </div>
  </template>
</turbo-stream>

Use for: status updates, progress indicators, replacing components.

Update — Replace innerHTML only (keeps the element):

<turbo-stream action="update" target="unread-count">
  <template>42</template>
</turbo-stream>

Use for: counters, badges, simple text updates.

Remove — Delete element entirely:

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

No template needed. Use for: deleting items, dismissing notifications.

Before — Insert before the target:

<turbo-stream action="before" target="footer">
  <template>
    <div class="banner">New announcement!</div>
  </template>
</turbo-stream>

After — Insert after the target:

<turbo-stream action="after" target="post-123">
  <template>
    <div class="comment">Great post!</div>
  </template>
</turbo-stream>

Morph — Smart-update that preserves state:

<turbo-stream action="morph" target="edit-form">
  <template>
    <form id="edit-form">
      <input name="email" value="alice@example.com">
      <div class="error">Invalid format</div>
      <button>Submit</button>
    </form>
  </template>
</turbo-stream>

Morph preserves input values, focus, scroll position—only updates what actually changed. Use for: forms with validation errors, live previews.

Refresh — Trigger a page refresh:

<turbo-stream action="refresh"></turbo-stream>

Use for: syncing after complex background changes. If page morphing is enabled, this does a smart morph instead of full reload.

Two Delivery Methods


Turbo Streams can arrive two ways:

1. HTTP responses — After form submissions
2. WebSocket/SSE — Real-time server pushes

Method 1: Streams Over HTTP


When a user submits a form, your server can respond with stream actions instead of a redirect.

Example: Adding a comment.

Page has:

<form action="/posts/1/comments" method="post">
  <textarea name="body"></textarea>
  <button>Post Comment</button>
</form>

<div id="comments">
  <div class="comment">Existing comment...</div>
</div>

<div id="comment-count">1 comment</div>

Server responds with Content-Type: text/vnd.turbo-stream.html:

<turbo-stream action="append" target="comments">
  <template>
    <div class="comment">
      <strong>Alice:</strong> Great post!
    </div>
  </template>
</turbo-stream>

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

Two things happen automatically: new comment appears, count updates. One form submission, multiple DOM updates, no JavaScript.

In Rails, this looks like:

def create
  @comment = @post.comments.create!(comment_params)
  
  respond_to do |format|
    format.turbo_stream
    format.html { redirect_to @post }
  end
end

With a create.turbo_stream.erb template:

<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment-count", "#{@post.comments.count} comments" %>

Method 2: Streams Over WebSocket


For real-time updates without user action, connect a WebSocket and broadcast streams.

The generic Turbo way uses <turbo-stream-source>:

<turbo-stream-source src="wss://example.com/messages">
</turbo-stream-source>

<div id="messages">
  <!-- Messages appear here -->
</div>

When the WebSocket receives a Turbo Stream message, it's automatically processed. Any <turbo-stream> elements that arrive get executed against the DOM.

In Rails with Action Cable, use the turbo_stream_from helper:

<%= turbo_stream_from @room %>

<div id="messages">
  <!-- Messages appear here -->
</div>

This renders a <turbo-cable-stream-source> element that connects to Action Cable with a signed stream name.

Broadcasting from your model:

class Message < ApplicationRecord
  belongs_to :room
  
  after_create_commit do
    broadcast_append_to room, target: "messages"
  end
  
  after_destroy_commit do
    broadcast_remove_to room
  end
end

When a message is created, everyone viewing that room sees it appear. When deleted, it disappears from everyone's screen. No polling, no client-side state.

Multiple Targets with CSS Selectors


Use targets (plural) with a CSS selector to update multiple elements:

<turbo-stream action="remove" targets=".flash-message">
</turbo-stream>

This removes all elements matching .flash-message.

Duplicate Prevention


If you append or prepend an element with an ID that already exists as a direct child of the target, Turbo replaces it instead of duplicating:

<!-- First time: appends -->
<turbo-stream action="append" target="messages">
  <template>
    <div id="message-42">Hello</div>
  </template>
</turbo-stream>

<!-- Second time: replaces existing message-42 -->
<turbo-stream action="append" target="messages">
  <template>
    <div id="message-42">Hello (edited)</div>
  </template>
</turbo-stream>

This prevents race conditions where the same update arrives multiple times.

Real-World Example: Chat


<%= turbo_stream_from @conversation %>

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

<form action="/messages" method="post">
  <input type="hidden" name="conversation_id" value="<%= @conversation.id %>">
  <input name="body" type="text" placeholder="Type a message...">
  <button>Send</button>
</form>

<div id="typing-indicator"></div>
<div id="participant-count">5 people online</div>

Model broadcasts:

class Message < ApplicationRecord
  belongs_to :conversation
  
  after_create_commit do
    broadcast_append_to conversation,
      target: "messages",
      partial: "messages/message"
  end
end

User sends message → saved to database → broadcast to all connected clients → appears on everyone's screen. Real-time chat with a few lines of code.

Real-World Example: Shopping Cart


<div id="cart-items">
  <div id="item-1" class="cart-item">Product 1 - $10</div>
  <div id="item-2" class="cart-item">Product 2 - $15</div>
</div>

<div id="cart-total">Total: $25</div>
<div id="cart-count" class="badge">2</div>

Add item response:

<turbo-stream action="append" target="cart-items">
  <template>
    <div id="item-3" class="cart-item">Product 3 - $20</div>
  </template>
</turbo-stream>

<turbo-stream action="update" target="cart-total">
  <template>Total: $45</template>
</turbo-stream>

<turbo-stream action="update" target="cart-count">
  <template>3</template>
</turbo-stream>

Remove item response:

<turbo-stream action="remove" target="item-2"></turbo-stream>

<turbo-stream action="update" target="cart-total">
  <template>Total: $30</template>
</turbo-stream>

<turbo-stream action="update" target="cart-count">
  <template>2</template>
</turbo-stream>

One action, multiple UI updates. Cart feels snappy without client-side state management.

When to Use Streams vs Frames vs Drive


Turbo Drive: User clicks link, entire page should change. Standard navigation.

Turbo Frames: User action should update one specific region. Modals, tabs, inline editing.

Turbo Streams: Server needs to update multiple parts of the page, or push changes without user action. Real-time features, form responses that affect multiple areas.

Most apps use all three together. Drive for navigation, Frames for scoped interactions, Streams for real-time and multi-part updates.

Common Mistakes


"Stream not applying": Check that the target element exists. Use document.getElementById('target-id') in browser console. If it's null, there's your problem.

"Content-Type issues": HTTP stream responses must have Content-Type: text/vnd.turbo-stream.html. Without it, Turbo treats the response as regular HTML.

"WebSocket not connecting": Check browser console for connection errors. Verify the URL is correct. Make sure <turbo-stream-source> is in the <body>, not <head>—Turbo replaces the body on navigation, so it needs to be re-rendered on each page.

"Duplicate elements": Give your streamed elements unique IDs. Turbo uses IDs to detect duplicates.

The Bottom Line


Turbo Streams deliver page changes as HTML fragments. Nine actions cover DOM operations. Two delivery methods: HTTP responses for form submissions, WebSocket/SSE for real-time pushes.

The power is in the simplicity. Server renders HTML, sends it over the wire, Turbo applies it. No JSON APIs, no client-side rendering, no state synchronization bugs.

Chat apps, real-time dashboards, live notifications, collaborative editing—all possible with the same server-rendered HTML approach you use for regular pages.

Next up: putting it all together. How Drive, Frames, and Streams work as a unified system.

References