Rails Turbo Morphing: Real-Time DOM Updates with broadcasts_refreshes
Rails Turbo Morphing surgically patches the DOM on page refresh. Learn broadcasts_refreshes, scroll anchoring, and when morphing beats custom ActionCable JS.
Six months ago I walked into a codebase where a React developer had bolted a fully custom real-time dashboard onto a Rails app. Every row in a table updated live. The charts refreshed on a timer. Clicking a row expanded it in-place without a page load. It was genuinely impressive. It was also three thousand lines of custom ActionCable JavaScript, a parallel serializer layer that duplicated the view logic already sitting in the ERB templates, and a one-month backlog of bugs where the JS state had drifted from what the database actually contained.
We replaced the whole thing in a week. The replacement was roughly eighty lines of Ruby, two model callbacks, and a single meta tag in the layout.
That is the pitch for Rails Turbo Morphing: real-time DOM updates that re-use your existing server-rendered templates, at the cost of almost nothing. After nineteen years of Rails I have rarely seen a feature so cleanly obsolete the pattern it replaced.
What Rails Turbo Morphing Actually Does
Turbo Drive — the default since Rails 7 with Hotwire — gives you single-page-app-style navigation by intercepting link clicks and form submissions, fetching the new page, and swapping the entire <body>. Fast, but coarse.
Turbo Frames, which I covered in the Turbo Frames deep-dive, lets you update named sections of a page independently. Surgical, but requires you to design your page around frame boundaries upfront.
Turbo Morphing sits between them. When an update arrives — from a form submission, an ActionCable broadcast, or an explicit redirect — Turbo uses a DOM-diffing library called morphdom to patch only the nodes that changed. The rest of the page stays in place: scroll position preserved, form inputs untouched, open dropdowns still open. The server still renders the full page. The browser applies only the diff.
There are two distinct mechanisms in Rails 8 that use morphdom:
- Page refresh morphing: Turbo’s full-page navigation uses morphdom instead of full
<body>replacement. Triggered by a redirect, a model broadcast, or a form submission that reloads the page. turbo_stream.morphaction: A Turbo Stream action that surgically patches a specific DOM target via ActionCable or arespond_to :turbo_streamblock.
Both use the same diffing logic. Most of what follows covers page refresh morphing because it handles ninety percent of real-time use cases with the least ceremony.
Setting Up Rails Turbo Morphing
You need Rails 8 with turbo-rails 2.0 or later. Check your Gemfile:
gem "turbo-rails", ">= 2.0"
Then enable morphing in your layout. Add one line inside <head> in app/views/layouts/application.html.erb:
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= stylesheet_link_tag "application" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
turbo_refreshes_with generates two meta tags that Turbo reads on the client:
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
That is the entire setup. Every subsequent page refresh — triggered by a redirect, a turbo_stream.refresh broadcast, or an explicit controller action — will now use morphdom to patch the DOM and preserve scroll position.
The Simple Case: broadcasts_refreshes on a Model
Once morphing is enabled in the layout, the simplest real-time pattern is a one-liner on your model:
class Order < ApplicationRecord
belongs_to :customer
has_many :line_items
broadcasts_refreshes
end
broadcasts_refreshes hooks into ActiveRecord callbacks — after_create_commit, after_update_commit, after_destroy_commit — and broadcasts a :refresh Turbo Stream action to a channel named after the record (for example, Order:42). Any connected browser subscribed to that stream receives the signal and triggers a page refresh using morphdom.
In the view, subscribe to the stream:
<%# app/views/orders/show.html.erb %>
<%= turbo_stream_from @order %>
<div id="order_<%= @order.id %>">
<h1><%= @order.reference_number %></h1>
<p>Status: <strong><%= @order.status %></strong></p>
<p>Total: <%= number_to_currency(@order.total) %></p>
<%= render @order.line_items %>
</div>
When any other user — or a background job — updates this order, every browser watching this page receives a turbo:refresh signal. Turbo fetches the page fresh from the server, diffs it against the current DOM with morphdom, and patches only what changed. The order status updates in place. The line items update. The scroll position does not move. Any text the user has typed into a form field on the same page is preserved because morphdom identifies matching DOM nodes by their id attribute and preserves their input state.
The server renders exactly the same template it always renders. There is no separate API endpoint, no serializer, no JSON shape to maintain alongside your views. The ERB template is the source of truth for both the initial render and every subsequent live update.
Broadcasting to Collections and Scoped Channels
broadcasts_refreshes broadcasts to the model’s own channel by default — Order:42 for order with id 42. That works perfectly for show pages. For index pages or dashboards where you want all connected clients to refresh when any record in a collection changes, broadcast to a broader scope:
class Order < ApplicationRecord
belongs_to :customer
# Notify anyone watching the general orders stream
broadcasts_refreshes_to :orders
# Notify anyone watching this specific customer's orders (multi-tenant safe)
broadcasts_refreshes_to -> { [customer, :orders] }
end
You can mix both. When an order saves, it notifies both the generic :orders stream and the customer-scoped stream. In the view, subscribe to whichever channel the page cares about:
<%# Index page — subscribe to the :orders stream %>
<%= turbo_stream_from :orders %>
<table id="orders-table">
<tbody>
<%= render @orders %>
</tbody>
</table>
When any order in the system updates, clients on this index page receive a refresh signal. morphdom diffs the rendered table against the current DOM and updates only the rows that changed. For an order management dashboard with multiple staff tabs open simultaneously, this is the complete real-time layer. No custom JavaScript required.
Scroll Anchoring and Preserving DOM State
scroll: :preserve in turbo_refreshes_with handles the most common complaint about full page refreshes: the viewport snapping back to the top. But there are two other scenarios you will hit within a day.
Persistent elements — modals that are open, dropdown menus, video players, anything that must not be replaced mid-interaction — get the data-turbo-permanent attribute:
<div id="notification-drawer" data-turbo-permanent>
<%# morphdom will skip this element entirely during patching %>
</div>
morphdom leaves any element with data-turbo-permanent exactly as it is. The element must have a stable id — morphdom matches persistent elements by their id attribute.
Temporary elements — flash messages, toast notifications, progress bars — should disappear when the page refreshes. Add data-turbo-temporary:
<%# app/views/shared/_flash.html.erb %>
<% flash.each do |type, message| %>
<div class="flash flash-<%= type %>" data-turbo-temporary>
<%= message %>
</div>
<% end %>
Turbo removes data-turbo-temporary elements before the morphdom diff runs. The refreshed render does not include the flash message (the redirect already consumed it from the session), so morphdom never tries to reconcile a stale notification against an empty server response. No more flash messages lingering across subsequent morphed refreshes.
Surgical Updates with turbo_stream.morph
Sometimes a full page refresh — even a morphed one — is broader than you need. You want to update one specific element after a form submission without triggering a round-trip for the entire page. turbo_stream.morph targets a single DOM element:
# app/controllers/notes_controller.rb
def update
@note = Note.find(params[:id])
if @note.update(note_params)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.morph(
"note_#{@note.id}",
partial: "notes/note",
locals: { note: @note }
)
end
format.html { redirect_to @note }
end
else
render :edit, status: :unprocessable_entity
end
end
turbo_stream.morph takes a target DOM id and a partial. It patches that single element using morphdom — not the whole page, just #note_42. The rest of the page is untouched. No Turbo Frame boundary to define upfront, no custom JavaScript, and the partial is the same one already used for the initial render.
This replaces turbo_stream.replace — which was destructive, removing the target and inserting fresh HTML — with a surgical diff. For most cases it is strictly better: form inputs inside the target are preserved if their values match the re-rendered HTML, CSS transitions animate correctly because existing elements are updated in place rather than removed and re-inserted, and Stimulus controller instances connected to child elements survive the patch.
When Rails Turbo Morphing Is the Wrong Tool
Morphing does not solve every real-time problem. I want to be honest about where it breaks down.
Collaborative editing: Two users simultaneously editing the same record will fight each other’s morphdom patches. morphdom tries to preserve data-turbo-permanent fields, but for any textarea or input without that attribute, a remote refresh will overwrite what the current user is typing. For true collaborative editing you need operational transform or CRDT — that is a different architecture entirely, and no amount of data-turbo-permanent makes morphdom the right fit.
Complex JavaScript owning the DOM: Any library that takes full ownership of a DOM subtree — a rich text editor like Trix, a charting library, a drag-and-drop kanban board — will conflict with morphdom patches on that subtree. The fix is data-turbo-permanent on the root element the library owns, combined with explicit refresh logic inside the JavaScript. That is often more work than it sounds, and sometimes more work than keeping the custom ActionCable layer you were trying to replace.
High-frequency updates: If a record broadcasts dozens of times per second, each morphed refresh still generates a full server render and a network round-trip. For sensor telemetry, live trading tickers, or auction bid feeds, fine-grained WebSocket messages are still the right answer. Solid Cable without Redis gives you the cable layer cheaply when you need it.
For everything else — dashboards, admin panels, order management, notification feeds, multi-user CRUD interfaces — Turbo Morphing is the right default in 2026. It re-uses your existing rendering pipeline, requires almost no additional code, and eliminates an entire category of JS-state-diverges-from-database bugs that have burned me more times than I care to count.
FAQ
What is the difference between Rails Turbo Morphing and Turbo Frames?
Turbo Frames replace a named section of a page with a server-rendered fragment. You define frame boundaries in the HTML upfront, and only the content inside that frame is updated on each interaction. Turbo Morphing refreshes the whole page but uses morphdom to patch only the changed DOM nodes — so you do not need to plan boundaries ahead of time. Morphing is simpler to introduce into an existing application; Frames are more explicit and easier to reason about when you need tight control over exactly what updates and when.
How does broadcasts_refreshes work under the hood?
broadcasts_refreshes registers after_create_commit, after_update_commit, and after_destroy_commit callbacks. When any fires, it broadcasts a Turbo Stream :refresh action to an ActionCable channel named after the record class and id (e.g. Order:42). Connected browsers subscribed via turbo_stream_from receive the signal and trigger a page refresh. If turbo_refreshes_with method: :morph is set in the layout, that refresh uses morphdom rather than full <body> replacement.
Does Rails Turbo Morphing preserve form state?
Yes, with an important caveat. morphdom matches elements by id attribute and preserves input values for elements it identifies as the same node across renders. If the server renders an <input id="order_note"> and the browser already has an <input id="order_note"> with unsaved user input, morphdom leaves the existing value alone. If the id changes — which happens when you re-render a collection and items shift position — morphdom cannot match elements reliably and may overwrite input state. Keep stable ids on any form elements you expect users to type into during a live session.
Can I use Rails Turbo Morphing alongside Stimulus controllers?
Yes, and the combination is clean. Stimulus attaches controllers to DOM elements by CSS selectors and fires connect/disconnect lifecycle callbacks when elements are added or removed. When morphdom patches the DOM, Stimulus fires disconnect for removed elements and connect for new ones. Existing elements that morphdom updates in place keep their Stimulus controller instances and any internal state those controllers hold. You get reactive DOM updates from the server side and fine-grained JavaScript behavior from Stimulus without the two stepping on each other.
Spending engineering time on custom ActionCable JavaScript that duplicates logic already in your ERB templates? TTB Software helps Rails teams replace brittle real-time JS layers with Turbo Morphing and conventional server-side rendering. We’ve been doing this for nineteen years.
Related Articles
RSpec Rails: Factory Bot, VCR, and the Test Suite Patterns That Actually Scale
RSpec Rails done right — Factory Bot associations, VCR for external APIs, shared examples, and CI tricks that keep yo...
Rails GraphQL: Production Setup with graphql-ruby, Batch Loading, and Persisted Queries
Rails GraphQL with graphql-ruby done right — schema design, N+1 prevention with batch loading, persisted queries, and...
Rails Postgres EXPLAIN ANALYZE: Reading Query Plans to Fix Slow Rails Queries
Rails Postgres EXPLAIN ANALYZE reveals where queries spend their time. Read plans, spot Seq Scans, fix N+1s, and tune...