← All posts

Jul 22, 2026

Hotwire Turbo 8: Page Refresh as a Broadcast, and Why Morph Changes the Math

Turbo 8's two headline features — page-refresh-as-broadcast and morph rendering — let you ship SPA-feeling UX without writing a controller per interaction. What it actually enables, and what's still in flight.

rails hotwire turbo turbo-8

I shipped my first Turbo Stream in 2021 and it felt like cheating. You wrote a server response, the browser updated a fragment, you were done. Three years and a lot of partial files later, Turbo 8 ships two ideas that move the needle again: page-refresh broadcasts and morph rendering. Together they reshape what “minimal Hotwire” looks like.

I’m referencing hotwired/turbo-rails at SHA 435135b26a4b62e49d2f55bb4b1fd419e3bfb228 and turbo-rails on rubygems. Turbo’s pace is fast enough that I’ll hedge on shipped-vs-experimental where relevant.

The pre-Turbo-8 baseline

Pre-8, the Turbo Streams story was: write partials, broadcast replace/append/remove actions to specific Turbo Frames, render them server-side. That’s clean and it works. But it’s coupled — the model knows what partial to render, what frame to target, what action to take. The pattern is:

# illustrative
class Comment < ApplicationRecord
  belongs_to :post

  broadcasts_to ->(comment) { [comment.post, "comments"] }, inserts_by: :prepend
end

Then in the view:

<%= turbo_stream_from @post, "comments" %>
<div id="comments"><%= render @comments %></div>

When a comment is created, every connected client receives a <turbo-stream action="prepend" target="comments"> message with the rendered partial inside. The partial filename has to match the model name. The action, target, and rendering all have to line up. It works, but you’re writing a partial per broadcast pattern.

Page refresh broadcasts

Turbo 8 lets you say “something changed; just re-fetch the whole page” from the server:

# illustrative
class Comment < ApplicationRecord
  belongs_to :post

  broadcasts_refreshes_to ->(comment) { [comment.post, "comments"] }
end

Now when a comment is created, the broadcast tells every connected client to re-request the current page (via Turbo’s normal navigation machinery). No partial, no target, no action.

Sounds wasteful, right? Re-fetching the whole page for one new comment? Two things make this work:

  1. The fetch is a normal cached page load. If you’ve Russian-doll-cached your fragments, the response is mostly hits, and the cost is one round-trip to render the assembled page.
  2. Morph rendering means the DOM doesn’t get blown away. Which is the second thing Turbo 8 ships.

Morph rendering

The default Turbo navigation behavior used to be: replace the <body> with the new one. Lose scroll position, lose form state, lose any client-side state in untouched parts of the page. Acceptable for a full navigation, awful for “a comment was added.”

Morph rendering uses idiomorph to diff the old DOM against the new one and apply the minimum set of changes. The comment that was added gets inserted; the rest of the DOM stays put. Scroll position is preserved. Inputs that were focused stay focused. Untouched fragments don’t even re-render.

You opt into morph for a page with a meta tag:

<!-- illustrative -->
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">

Now any Turbo refresh on this page (whether triggered by broadcasts_refreshes_to, by clicking a link, or by submitting a form) uses morph instead of replace. The user-visible experience is “the comment appeared” without any of the disorientation of a full page swap.

Why this changes the math

Pre-8, the right thing to do for “live updating list” was to write a partial, set up the right broadcasts_to, target the right frame. You traded controller code for partial-and-broadcast bookkeeping. That’s still less code than React, but it’s not zero.

With morph + page-refresh broadcasts, the workflow becomes:

  1. Render your page like a normal Rails page. No partials specifically for broadcasting, no frame targeting.
  2. On the model: broadcasts_refreshes_to <stream key>.
  3. In the layout (or relevant page): the morph meta tags.
  4. In the view: <%= turbo_stream_from @post %>.

The first time you see the page update without a flicker, with scroll position preserved and the only DOM change being the new element appearing — and you remember you didn’t write any JavaScript, didn’t write a partial, didn’t think about which fragment to target — it lands.

What’s actually shipped vs in-flight

A hedge: Turbo’s release cadence is fast and “Turbo 8” has been a moving target across multiple point releases. Page-refresh broadcasts and morph rendering are both shipped in turbo-rails 2.x at the SHA referenced above. The exact ergonomics (helper names, meta tag syntax, configuration options) have shifted between minor versions; check the turbo-rails CHANGELOG for the version you’re on.

What I haven’t tested in production yet:

  • High-frequency morph. I’ve used morph for “a comment every few seconds” patterns, not “a stock ticker updating every 200ms.” The diff cost on a complex page might matter at higher frequencies; I’d benchmark before committing.
  • Morph + JS-heavy components. If you have Stimulus controllers managing significant client state, the interaction with morph is mostly fine — Stimulus controllers persist across morphs because their elements persist — but there are edge cases (data attribute changes, controller reconnection) worth testing.

What you give up

Two things, mostly:

  1. Targeted updates are gone for the morph-refresh path. You’re saying “re-render the page; let morph figure it out.” If you have specific knowledge that only one fragment changed, the targeted broadcast is more efficient. The targeted broadcasts still exist — broadcasts_to with inserts_by: :prepend still works — they’re just no longer the default recommendation.
  2. Server load on broadcast events. Every connected client receives the refresh message and re-fetches the page. If you have 10,000 viewers of one page and one comment is added, that’s 10,000 page renders. Caching helps, but it’s a real cost. For a Twitter-scale fanout, this isn’t the pattern. For a forum, a feature flag dashboard, an admin UI — it’s almost always the right call.

Where this leaves Hotwire

Hotwire’s pitch was always “you don’t need a single-page-app for most of what you build.” Turbo 8 makes that pitch sharper. The shape of “live, interactive Rails app” is now:

  • ERB views, server-rendered
  • Stimulus for the small bits of client behavior
  • broadcasts_refreshes_to + morph for live updates
  • Targeted Turbo Streams when you have a specific reason to

The first three are zero JavaScript on your part. The last one is the escape hatch for when you need the precision.

If you’ve been on the fence about Hotwire because the partial-and-broadcast bookkeeping felt like too much, Turbo 8 removes most of it. Worth another look.