Skip to main content
SXN Labs
Back to articles
Hotwire Turbo Rails Morphing Stimulus 06 August 2026

Turbo doesn't have three tools, it has three scopes

Turbo’s documentation has a reputation for being incomplete. It isn’t, not really: every page says roughly what it should say. The problem is elsewhere. Drive, Frames and Streams are documented side by side, as three separate products, with nothing saying what connects them or how to choose. You learn the syntax of each one, and you stay stuck on the only question that matters when you sit down to write: which one, here, now.

There is an answer, and it is simple. Turbo does exactly one thing: it intercepts a navigation, fetches HTML, and replaces a portion of the document. Drive, Frames and Streams are not three mechanisms, they are three scopes of that replacement: the whole page, a named fragment, or any set of elements. And each scope answers the question that governs everything else: who decides the target, the client or the server.

What follows is what I wish I had read three years ago. It is long, it is meant to be reread in pieces, and it ends with an index that starts from the observable symptom. The reference versions are Turbo 8.0.23 and turbo-rails 2.0.23, both released on January 29, 2026, on Rails 8.

The model in one page

Three panels comparing Turbo Drive, which replaces the whole page, Turbo Frames, which replaces a fragment designated by the client, and Turbo Streams, which applies commands to targets designated by the server.
The same mechanism, three scopes. What changes from one column to the next: the extent of the replacement, and who designates its target.

With Drive, the target is implicit: it is the <body>. The client decides to navigate, the server responds with a page, Turbo swaps the body and merges the head.

With Frames, the target is decided by the client before the request even leaves. The emitting frame puts its identifier in a Turbo-Frame header, and Turbo will accept from the response only the element carrying that same identifier. The server chooses nothing: it is subject to a constraint.

With Streams, the target is written into the response. The server says replace the element with this id, append this to the end of that one, remove that other one. It can aim at several places, not contiguous, and it can do so without anyone having asked for anything, over a WebSocket.

Everything else follows from that. A frame that does not update is almost always an identifier that does not match. A stream that has no effect is almost always a target missing from the DOM. Morphing, which we will get to, does not change this model: it only changes how the replacement is applied, not who decides it.

Turbo Drive

Drive is active as soon as you load @hotwired/turbo. There is nothing to write to benefit from it and, contrary to what the order of the documentation’s chapters suggests, that is where the majority of needs should stop.

What is intercepted, and what is not

Drive intercepts clicks on same-origin <a href> elements and form submissions. It does not intercept: links with a target attribute other than _self, download links, cross-origin URLs, and URLs whose extension appears in Turbo.config.drive.unvisitableExtensions (about fifty extensions, including .pdf, .zip, .csv, .jpg). That last list is configurable and is documented nowhere on the official site.

You switch it off case by case with data-turbo="false" on the element or any of its ancestors. A data-turbo="true" nested further down turns it back on.

The lifecycle

Vertical sequence of the events of a Drive visit, from turbo:click to turbo:load, with the preview from the cache and the body replacement step.
The dashed boxes are not events: they are the internal steps the events bracket.

Two points are worth stopping on, because they explain a good half of all lifecycle bugs.

The snapshot is taken of the page you are leaving, not the one you are loading. turbo:before-cache fires on the live document, right before Turbo makes a cloneNode(true) of it. So this is your last chance to undo what your JavaScript added to the DOM. And there is a cruel asymmetry: the clone loses the listeners but keeps the injected DOM. That is exactly the recipe for the library initialized twice on back navigation, which we will get to.

Useful detail: the clone also restores the selection of <select> elements (which cloneNode loses), clears the value of every input[type=password], and removes <noscript> elements.

A preview from the cache fires turbo:render twice. If the target URL is in the cache, Turbo displays the snapshot immediately while the network request runs. During that preview, <html> carries the data-turbo-preview attribute. That is how you test for it in a Stimulus controller:

connect() {
  if (document.documentElement.hasAttribute("data-turbo-preview")) return
  this.initExpensiveWidget()
}

The cache, in numbers

The Drive cache is an LRU of ten snapshots, in memory, in the tab. No localStorage, no IndexedDB, no Cache API. It dies when the tab closes, on reload, and it is emptied in full on every unsafe form submission. It contains only HTML: no stylesheet, no image, no script.

Remember the phrasing: Turbo’s cache is a perceived-latency optimization, it is not a persistence layer. It gives you strictly nothing offline.

Three levers:

What you want How
Never cache this page <meta name="turbo-cache-control" content="no-cache">
Cache it but never show it as a preview <meta name="turbo-cache-control" content="no-preview">
Remove an element before caching data-turbo-temporary on the element

data-turbo-cache="false" was removed in 8.0.21, in January 2026, after three years of deprecation. So was Turbo.clearCache(), replaced by Turbo.cache.clear(). Both still linger in a pile of blog posts, and in Stack Overflow answers, that site where humans used to write documentation for one another.

Turbo.config

Since 8.0.6, configuration goes through a single object. The old setter functions (setProgressBarDelay, setConfirmMethod, setFormMode) still exist but emit a console warning.

Turbo.config.drive.progressBarDelay = 500          // ms before the progress bar
Turbo.config.drive.enabled          = true
Turbo.config.forms.mode             = "on"         // on | off | optin
Turbo.config.forms.submitter        = "disabled"   // disabled | aria-disabled
Turbo.config.forms.confirm          = async (message) => { /* Promise<boolean> */ }

forms.submitter deserves a word, because it is a case where Turbo’s default is bad for accessibility. By default, Turbo sets the disabled attribute on the button during submission. A disabled button stays exposed in the accessibility tree, but it loses its focusability and its place in the tab order: focus falls back to <body>, and someone navigating by keyboard or screen reader loses their position on every form submission. With "aria-disabled", Turbo sets aria-disabled="true" and cancels click events on the button: the protection against double submission is identical, but the control stays focusable.

Turbo.config.forms.submitter = "aria-disabled"

One line, in application.js. There is no reason not to write it.

Prefetching

Since Turbo 8, prefetch on hover is on by default. Turbo waits 100 ms after mouseenter, sends a GET with the X-Sec-Purpose: prefetch header, and keeps the response in a single-entry cache for 10 seconds.

That means moving the mouse over a link fires a request against your server. If your GET actions are not idempotent, or if your server does not like free traffic, switch it off:

<meta name="turbo-prefetch" content="false">     <%# global %>
<a href="/x" data-turbo-prefetch="false"></a>   <%# per element %>

Not to be confused with data-turbo-preload, which is a different mechanism, a different cache, and a load on DOMContentLoaded rather than on hover.

Turbo Frames

It all comes down to one identifier

A frame sends the Turbo-Frame header, the server responds with a full page, Turbo extracts only the turbo-frame with the same id, and the rest is discarded. Below, the frame-missing sequence.
Turbo does exactly one thing with the response: it looks for a <turbo-frame> with the same id. Everything else is discarded.

The matching rule, literally, is container.querySelector("turbo-frame#" + CSS.escape(id)). No fuzzy matching, no configurable selector. That is why the most robust pattern is to let the server echo back the identifier it was sent, through turbo-rails’ turbo_frame_request_id helper:

<%= turbo_frame_tag turbo_frame_request_id || "invoice_detail" do %><% end %>

The fallback is not decorative: on a full-page visit the header is absent and turbo_frame_tag nil produces id="", which silently breaks every later navigation to that frame.

There is a second chance, rarely used: the recurse attribute. If no frame with the same id is found but a <turbo-frame src recurse~="my-id"> is present, Turbo waits for it to load and then looks inside it.

And if nothing matches, the sequence is this:

  1. complete is set on the frame, before the event.
  2. turbo:frame-missing is dispatched on the frame, cancelable, with detail.response (a raw Response) and detail.visit(urlOrResponse, options).
  3. If nobody cancels, the frame displays <strong class="turbo-frame-error">Content missing</strong> and Turbo throws a TurboFrameMissingError.

The most frequent case is session expiry: the request leaves from the frame, the server redirects to /login, and the login page obviously does not contain your frame. The right answer is not to handle the event, it is to mark the login page:

<%= turbo_page_requires_reload %>

which emits <meta name="turbo-visit-control" content="reload">. Turbo then skips frame extraction entirely and performs a full-page visit. That is the intended escape hatch, and it is far better than a global turbo:frame-missing listener.

A trap while we are here, and it applies to turbo_refreshes_with further down too: this helper calls provide :head. It writes nothing at the call site, despite the <%= %>. Without <%= yield :head %> in your layout, the meta tag never comes out and nothing happens. The turbo_page_requires_reload_tag variant renders the tag in place if you prefer to position it yourself.

The layout, and the static layout trap

turbo-rails installs this into ActionController::Base:

layout -> { "turbo_rails/frame" if turbo_frame_request? }
etag   { :frame if turbo_frame_request? }

The turbo_rails/frame layout is minimal (just csrf_meta_tags and yield :head), not absent, so that content_for :head and CSRF keep working.

The trap: if you write layout "admin" in a controller, you overwrite that lambda. Frame requests will then render the full layout. It still works, because Turbo extracts the frame anyway, but you pay for the entire layout on every frame request, and all the JavaScript injected into the <head> is re-evaluated. You have to convert the declaration into a method:

layout :layout_for_request

private

def layout_for_request
  turbo_frame_request? ? "turbo_rails/frame" : "admin"
end

No error, no warning. Just a silent bill.

The attributes that matter

Attribute Effect
src Loads this URL into the frame. Passed through url_for, so a model works.
loading="lazy" Loads only on entry into the viewport, via an IntersectionObserver.
target Default target for descendant links and forms.
data-turbo-frame On a link, a form or a submit button. Overrides target. The button wins over the form.
_top Breaks out of the frame: Drive treats the navigation as a full-page visit.
_parent Targets the nearest ancestor frame. Added in 8.0.21.
busy Set by Turbo during loading, along with aria-busy="true". Useful in CSS.
complete Set after a successful render. Careful: the JS property frame.complete does not read this attribute.
disabled Cancels the in-flight request and ignores any navigation.
autoscroll Scrolls to the frame after rendering. data-autoscroll-block defaults to end, not start.
refresh="morph" With src, the frame is reloaded rather than morphed during a page morph.

Two behaviors that often surprise people:

A frame renders 4xx and 5xx exactly like 2xx. The status code is not consulted at all. Only two things can divert the response: a non-HTML Content-Type, in which case loadResponse does nothing at all, with no event and no error, and the turbo-visit-control: reload seen above, which triggers a full-page visit with a console warning.

A form inside a frame does not need to redirect. The “form responses must redirect” constraint only applies to full-page submissions. A 200 OK with HTML is perfectly valid inside a frame.

Turbo Streams

A <turbo-stream> is an envelope. It carries an action, a target, and a <template>:

<turbo-stream action="replace" target="invoice_42">
  <template><div id="invoice_42"></div></template>
</turbo-stream>

There are two worlds behind this format, and conflating them is the number one source of confusion.

The first world is an HTTP response: the user does something, the server responds with text/vnd.turbo-stream.html, the tab applies it. It is synchronous, it lives in the request context, current_user exists.

The second is a broadcast: a model publishes on an Action Cable channel, every subscribed tab receives it. It is asynchronous, it runs in a job, there is no request, no session, no current_user. We come back to it below, because that is where the real problems hide.

If you knew Rails before 2021, the first world will ring a bell. Answering a submission with a document that describes DOM mutations is exactly what create.js.erb did back in the UJS days. The difference comes down to two points, and they are decisive: the server returns HTML instead of returning JavaScript to evaluate, and the mutation vocabulary is closed at eight actions instead of being “everything jQuery knows how to do”. We lost freedom and gained a zero attack surface, an HTTP cache that works, and responses you can read.

The full path, from controller to view

Here is the canonical server-side form, the one you write in 90% of cases.

# app/controllers/invoices_controller.rb
def create
  @invoice = Invoice.new(invoice_params)

  if @invoice.save
    respond_to do |format|
      format.turbo_stream                                    # -> create.turbo_stream.erb
      format.html { redirect_to @invoice, status: :see_other }
    end
  else
    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: turbo_stream.replace(
          "invoice_form", partial: "invoices/form", locals: { invoice: @invoice }
        ), status: :unprocessable_entity
      end
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end

Two ways to respond, and you should choose knowingly. The bare format.turbo_stream renders a template, which is the right choice as soon as several zones change. The inline render turbo_stream: renders a single action and saves a file, which is the right choice when there really is only one.

The template itself is a plain ERB file that yields the builder:

<%# app/views/invoices/create.turbo_stream.erb %>
<%= turbo_stream.prepend "invoices", @invoice %>
<%= turbo_stream.update "invoices_count", Invoice.count %>
<%= turbo_stream.replace "invoice_form" do %>
  <%= render "invoices/form", invoice: Invoice.new %>
<% end %>

Three remarks on those three lines, because they condense almost the whole API.

turbo_stream.prepend "invoices", @invoice takes no partial: passing a record is enough, the builder calls to_partial_path and renders invoices/_invoice.html.erb. That is also why this partial must render an element with id="<%= dom_id(invoice) %>", without which nothing will be able to target it afterwards.

turbo_stream.update accepts a raw string as content, which saves a one-line partial for a counter.

The block form captures whatever you write inside it. Handy, but it is also the one place where it is easy to forget that the content is rendered outside the page context, so without the ivars set by some other before_action.

And on the calling view side, the rule is the same as everywhere: what the stream aims at must exist with the right id.

<%# app/views/invoices/index.html.erb %>
<div id="invoices_count"><%= @invoices.count %></div>

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

<div id="invoice_form">
  <%= render "invoices/form", invoice: Invoice.new %>
</div>
<%# app/views/invoices/_invoice.html.erb %>
<div id="<%= dom_id(invoice) %>" class="invoice">
  <%= invoice.reference %>
  <%= button_to "Supprimer", invoice, method: :delete, form: { data: { turbo_confirm: "Sûr ?" } } %>
</div>

Note data-turbo-confirm on the button_to form, the direct successor to rails-ujs’ data-confirm. The difference is that it is pluggable: Turbo.config.forms.confirm accepts your own function, which finally lets you replace the native dialog with your own modal without rewriting the mechanism.

Eight actions, and morph is not one of them

This is the most widespread mistake in the whole ecosystem, including in widely read blog posts.

Action What it does Target required
append Adds to the end of the target’s content
prepend Adds at the beginning
before Inserts before the target
after Inserts after the target
replace Replaces the target itself
update Replaces the target’s content
remove Removes the target. No <template>
refresh Triggers a page refresh. No <template>

That is all. There is no morph action. Morphing is an attribute, method="morph", and among the targeted actions only replace and update read it. Writing turbo_stream.append("x", method: :morph) does produce the attribute in the HTML, and the JavaScript handler for append ignores it purely and simply. refresh reads method too, but for another reason: to choose the render mode of the page refresh there, broadcast by broadcast.

Two barely documented behaviors:

append and prepend deduplicate: if a direct child of the target carries the same id as a top-level incoming element, the old one is removed. append therefore behaves like an upsert, which is very handy and never stated. Since 8.0.21, before and after do the same thing on the target’s siblings.

refresh can be deduplicated through a request-id attribute, the subject of the broadcasts section.

target and targets are not the same thing

turbo_stream.replace     "invoice_42"        # target  => getElementById
turbo_stream.replace_all ".invoice-row"      # targets => querySelectorAll

target takes a bare DOM id, not a selector, and resolves to at most one element. targets takes a CSS selector and applies the action to every match. If both are present, target wins.

On the Ruby side, this difference in kind is absorbed for you. If you pass a record rather than a string, the helper puts the hash in the right place:

turbo_stream.replace     @invoice   # target="invoice_42"    <- bare dom_id
turbo_stream.replace_all @invoice   # targets="#invoice_42"  <- dom_id with the #

So the trap only shows up the day you write the selector by hand, because at that point nobody is correcting it any more: turbo_stream.replace_all "invoice-row" without the dot or the hash matches nothing, and says nothing.

The silence when the target does not exist

This is the most expensive behavior in all of Turbo in debugging time.

If document.getElementById(target) returns null, the getter returns an empty array, and every action iterates over that empty array. No warning, at any log level. The stream arrives, it is visible in the Network tab, it is visible in the Rails logs, and nothing happens.

The classic causes: a misspelled or accidentally pluralized dom_id, a target living inside a <template> or an <iframe> (explicitly unsupported), or a target inside a loading="lazy" frame that has not loaded yet.

There is a second silent variant, sneakier: on a GET request, Turbo sends the Accept: text/vnd.turbo-stream.html header only if the link or the form carries data-turbo-stream. Without that attribute, your respond_to will never see the turbo_stream format and will fall into the HTML branch.

Adding your own actions

The mechanism is simpler than it looks and it is a good investment as soon as you catch yourself stacking streams to express a single intention.

On the Ruby side, a load hook:

# config/initializers/turbo.rb
ActiveSupport.on_load :turbo_streams_tag_builder do
  def highlight(target)       = action     :highlight, target
  def highlight_all(targets)  = action_all :highlight, targets
end

On the JavaScript side, an entry in Turbo.StreamActions, where this is the <turbo-stream> element:

Turbo.StreamActions.highlight = function () {
  this.targetElements.forEach((el) => {
    el.animate([{ backgroundColor: "#FFD83F" }, { backgroundColor: "transparent" }],
               { duration: 1200 })
  })
}

And from a model, without going through the builder:

after_update_commit -> {
  broadcast_action_to "plannings", action: :highlight, target: "gantt", html: ""
}

If the action is not registered on the JavaScript side, the element throws unknown action. It is one of the rare places where Turbo is loud.

The HTTP status, and why 422

Decision tree: turbo-stream content-type, then frame request, then HTTP status, with the four outcomes 200, 4xx, 5xx and 3xx.
Three questions, in this order. The HTTP status is consulted last, and only for a full-page navigation.

The number one symptom in all of Hotwire comes down to one line of Turbo’s code:

responseSucceededWithoutRedirect(response) {
  return response.statusCode == 200 && !response.redirected
}

If you answer a POST with 200 and HTML and no redirect, Turbo prints console.error("Form responses must redirect to another location") and renders nothing. The page looks frozen. The form went out, the progress bar ran all the way, and the error messages never appeared.

The reason is given in the manual, and it is a good one: browsers have native behavior for reloading a page that came from a POST, that “do you want to resubmit the form?” dialog, which Turbo cannot reproduce. Rather than lie about the URL, it refuses.

Hence the Rails convention, which the scaffold generator already applies:

render :new, status: :unprocessable_entity  # 422: the response is rendered, the URL does not move
redirect_to @invoice, status: :see_other    # 303: after update and destroy

The 303 is not a Rails whim, it is a consequence of the Fetch specification. Turbo passes redirect: "follow" and lets the browser follow. And:

So a redirect_to invoices_path after a destroy without status: :see_other sends a DELETE /invoices to your server. At best a routing error, at worst something you had not planned for.

One nuance that makes life easier: a turbo-stream response short-circuits all of this. Turbo intercepts on the Content-Type before it even looks at the status. render turbo_stream: …, status: :unprocessable_entity works perfectly, and the 422 now only serves your tests and non-Turbo clients.

A small amusing detail: the test is on statusCode == 200 exactly. A 201 Created goes straight through the guard and carries on to a visit.

Morphing

Turbo 8 introduced page refreshes with morphing. The idea: rather than replacing the <body>, compare the old tree with the new one, and change only what differs. You keep the scroll, the focus, the text selection, the state of CSS transitions.

You turn it on in two lines, in the layout:

<%= turbo_refreshes_with method: :morph, scroll: :preserve %>

And here is the first trap, responsible for a good share of the “morphing does not work for me” reports: turbo_refreshes_with calls provide :head. It writes nothing where you call it. Without <%= yield :head %> in your layout, the meta tags never come out, and Turbo keeps doing classic replacements, without reporting anything. The accepted values are :replace or :morph for method:, :reset or :preserve for scroll:, anything else raises an ArgumentError.

What actually triggers a morph

A morph only happens for a page refresh, and the exact condition comes down to two clauses: same pathname (the query string and the fragment do not count) and action === "replace".

In practice, for a form, that amounts to “same URL”, because Turbo only picks replace if the destination URL is strictly identical to the starting URL. A POST /invoices/42 that redirects to /invoices/42 morphs. A POST /invoices/42/edit that redirects to /invoices/42 does not morph: two different paths, classic rendering.

But the action clause counts for the rest. A data-turbo-action="replace" link from /invoices?page=2 to /invoices?page=3 morphs too, since the pathname is the same.

The two most common triggers: a form that redirects to the same URL, and a <turbo-stream action="refresh"> broadcast.

What morphing removes

Comparison of the two render pipelines: classic, with before-cache, body replacement and script re-execution, against morph, where before-cache no longer happens and nothing is re-executed.
Same beginning, same end. It is the middle that changes, and the middle is where your code lives.

Three corrections to some very widespread folklore:

turbo:load does fire after a morph. A refresh is a real visit. What does not replay is the inline <script> tags.

turbo:before-cache no longer fires. The refresh passes shouldCacheSnapshot: false. And turbo:before-cache is the global teardown point recommended everywhere since Turbolinks. Turning morphing on therefore silently disables the cleanup code of half the existing Rails applications.

Scripts are not “never re-executed”, they are re-executed if they are new. idiomorph matches the old <script> with the new one by tag name, then syncs the text content with oldNode.nodeValue = newNode.nodeValue. Assigning nodeValue on an already executed script never re-executes it. A genuinely new script, on the other hand, with no partner in the old tree, is inserted and runs.

idiomorph decides by identifiers

An id is considered stable only if all three of the following conditions hold: it exists in both trees, the tag name is identical, and it is duplicated in neither of them.

That third condition deserves a box around it. A single duplicated id anywhere in the document takes that id out of the set of persistent identifiers, in both copies. Matching then falls back to position. So an accidental duplicate in a partial degrades the morphing of elements that have nothing to do with it.

Without stable identifiers, everything is paired by position. Adding a row at the top of a list rewrites the text of every row rather than inserting a node. Consequences: CSS transitions restart everywhere, and any client state carried by a row (an open menu, a checked box, a playing video) shifts by one.

The rule is short: give every list item a stable, unique id, never reuse it, never change the associated tag. dom_id(record) does exactly that.

The pantry, and why it breaks in Safari

Here is the mechanism that explains the “it works on my machine but not on his” bug reports, and that is documented nowhere.

When a node with a persistent identifier has to change place, idiomorph does not clone it. It moves it into a hidden <div> inserted after </body>, the pantry, then puts it back in position later. The move uses parentNode.moveBefore() if the browser provides it, otherwise insertBefore.

This is not an implementation detail, it is a fork in behavior:

As of this writing, the insertBefore camp has shrunk to Safari and WebKit iOS, plus versions earlier than Chrome 133 and Firefox 144. So yes, a morphing bug can be perfectly reproducible in Safari and nowhere to be found in Chrome. It is not your code.

Field values are not protected

idiomorph does not only sync attributes, it also writes the live DOM properties of form controls. The code is explicit:

if (!newElement.hasAttribute("value")) {
  if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
    oldElement.value = ""           // what the user had typed
    oldElement.removeAttribute("value")
  }
}

And a server-rendered <input type="text"> normally has no value attribute. So a refresh that arrives while someone is typing erases what they are typing. Same treatment for checked, disabled, <option selected> and the content of <textarea> elements.

idiomorph has an ignoreActiveValue option that excludes document.activeElement from this synchronization. Turbo does not enable it, and does not expose it anywhere: you cannot set it.

So it is on you to protect the field yourself, and the only lever available on the Turbo side is data-turbo-permanent, which skips the morph on the element. Since you do not want to freeze the field permanently, you set it on focus and remove it on the way out. This snippet comes from Turbo’s own test fixtures, which makes it the most official answer in existence:

addEventListener("focusin", ({ target }) => {
  if (target instanceof HTMLInputElement && !target.hasAttribute("data-turbo-permanent")) {
    target.toggleAttribute("data-turbo-permanent", true)
    target.addEventListener("focusout", () => {
      target.toggleAttribute("data-turbo-permanent", false)
    }, { once: true })
  }
})

On focus, more precisely: idiomorph restores focus, but only for an <input> or a <textarea> that carries an id. A focused <select>, a focused contenteditable or a field without an id lose focus, and there is no option to change that.

And autofocus is not honored at all under morph: MorphingPageRenderer declares shouldAutofocus as false.

The protection toolbox

From the finest to the most brutal:

// 1. Protect one specific attribute
document.addEventListener("turbo:before-morph-attribute", (event) => {
  const { attributeName } = event.detail            // + mutationType: "update" | "remove"
  if (attributeName === "open") event.preventDefault()
})

// 2. Protect an entire subtree
document.addEventListener("turbo:before-morph-element", (event) => {
  if (event.target.matches(".widget-tiers")) event.preventDefault()
})

// 3. Reinitialize afterwards (turbo:morph-element)
document.addEventListener("turbo:morph-element", ({ target }) => { /* … */ })
<%# 4. The complete freeze %>
<div id="carte" data-turbo-permanent></div>

Two clarifications the documentation does not give.

data-turbo-permanent does not have the same semantics depending on the render mode. In classic rendering, the selector is [id][data-turbo-permanent]: the id is mandatory, and the live node is transplanted into the new body. Under morph, only the attribute is tested, the id is not required, and the node is simply skipped. The id becomes necessary again for added nodes, where it serves for deduplication.

Under morph, data-turbo-permanent freezes the subtree completely. Legitimate server updates inside it will never arrive. It is the tool of last resort, not the reflex.

Finally, turbo:before-morph-element is also dispatched for nodes about to be removed, and in that case detail.newElement is undefined. A listener that writes event.detail.newElement.matches(…) will throw a TypeError sooner or later.

Broadcasts

This is where the mental model needs to be soundest, because the code is written in one line and the problems show up in production.

Four macros, and one asymmetry

class Card < ApplicationRecord
  broadcasts_refreshes_to :board   # a single after_commit, everything goes to board
end
Macro Create Update Destroy
broadcasts_to :board append to board replace to board remove to board
broadcasts append to "cards" replace to the record’s GID stream remove to the GID stream
broadcasts_refreshes refresh to "cards" refresh to the GID stream refresh to the GID stream
broadcasts_refreshes_to :board refresh to board refresh to board refresh to board

Read the second and third rows twice. broadcasts and broadcasts_refreshes send creates to the collection stream, but updates and destroys to the record’s own stream. A page that only does turbo_stream_from "cards" will see the new cards appear and will never see the updates or the deletions.

This is intentional: the intended pattern is turbo_stream_from Card on the index and turbo_stream_from @card on the detail page. But it is written nowhere in readable form. If you want all three events to go to the same place, the macro to use is broadcasts_refreshes_to, which installs a single after_commit.

Another asymmetry worth knowing: for the first three macros, deletions are synchronous while creates and updates go through a job. That is logical (a deletion has nothing to render), but it means the deletion is not debounced. broadcasts_refreshes_to escapes this precisely because it installs only one after_commit: everything there is asynchronous and debounced, including the destroy.

The full path of a refresh

Sequence diagram between the writing tab, the server and a watching tab, showing the X-Turbo-Request-Id header, the 0.5 second debounce, and the refresh being dropped in the originating tab.
The tab that writes receives its own broadcast. The whole identifier mechanism exists to let it ignore that broadcast.

Every Turbo fetch generates a UUID, adds it to a set capped at 20 entries, and sends it as X-Turbo-Request-Id. On the Rails side, an around_action copies it into Turbo.current_request_id. The broadcast re-emits it in the request-id attribute. On reception, Session#refresh ignores the refresh if the identifier is in its local set.

The reason is a good one: the tab that wrote has already displayed the result of its request. Replaying the refresh would cost a pointless round trip, the scroll position and the focus.

Four ways to lose this protection, all verifiable in the code:

  1. broadcast_refresh_to, the synchronous variant, transmits no request_id. Only the _later_ variant does. It is also the variant the macro installs for destroy.
  2. From a background job, Turbo.current_request_id is nil: it is a thread_mattr_accessor set by an around_action. Every tab refreshes, including the one that started it. That is generally what you want.
  3. The set is capped at 20. More than 20 Turbo requests between the send and the arrival of the broadcast and the identifier is evicted. With prefetch on hover on by default, that is no longer so theoretical.
  4. turbo_stream.refresh rendered as a direct response to a request carries that request’s identifier by default, so the requesting tab ignores it. You have to write turbo_stream.refresh(request_id: nil).

The debounce, and what it costs

broadcast_refresh_later_to goes through a Turbo::ThreadDebouncer memoized in Thread.current, keyed on (stream name, request_id), which schedules a Concurrent::ScheduledTask 0.5 seconds into the future. Each new call cancels the previous one. A thousand records modified in one request therefore give one broadcast.

On the client side, Session#refresh is debounced at 150 ms on top of that.

Two less pleasant consequences:

In a short-lived process, the broadcast is never sent. A rails runner, a rake task, a container that exits after its work: the process ends before the scheduled task fires. No error. The documented workaround is a sleep Turbo::Debouncer::DEFAULT_DELAY + 0.1.

In tests, there is no debounce at all. turbo-rails installs a Turbo::ImmediateDebouncer in the test environment. Your assertions count N broadcasts, production will see 1.

current_user does not exist in a broadcast

Every asynchronous broadcast renders its partial through:

ApplicationController.render(formats: [format], **rendering)

That is, an ActionController::Renderer with a synthetic Rack environment. There is no session, no cookies, no Warden key. Mechanical consequences:

The pattern that scales is the only one that accepts that the broadcast HTML is the same for every recipient: broadcast a neutral partial, and have the personalized parts loaded by a frame, which each browser will go and fetch with its own cookies.

<%# the broadcast partial, identical for everyone %>
<div id="<%= dom_id(card) %>">
  <%= card.title %>
  <%= turbo_frame_tag "#{dom_id(card)}_actions", src: card_actions_path(card), loading: :lazy %>
</div>

The alternatives: broadcast one stream per user, which is correct but O(users), or explicitly pass everything you need through locals: and treat “this partial is broadcastable” as a strict property of the partial.

It is also, incidentally, the best argument in favor of broadcasts_refreshes: a refresh renders nothing on the server. Each browser makes its own request, with its own session. The problem disappears by construction.

A signed stream is not an authorized stream

<%= turbo_stream_from "quotes" %>

This line produces a signed-stream-name that is identical for every user. The signature prevents forgery, not reading: the name is a MessageVerifier, so signed base64, not encrypted. Two base64 -d calls separate your page source from the string gid://app/Account/5, and nobody needs your key to run them.

And Turbo::StreamsChannel#subscribed accepts the subscription with no additional check whatsoever. It verifies that the signature is valid, full stop. Anyone holding a valid signed name can subscribe, including a former employee whose access you revoked, indefinitely: these names do not expire.

So you have to carry the scope in the stream name:

broadcasts_refreshes_to ->(quote) { [quote.company, :quotes] }
<%= turbo_stream_from current_company, :quotes %>

And if you need real authorization, for example because membership can be revoked, you have to write your own channel and do the check before stream_from.

Two plumbing traps

In development, the default Action Cable adapter is async, single-process. A broadcast triggered from bin/rails console will never reach a browser connected to a separately started server. Switch to redis in config/cable.yml, or use <%= console %> to trigger it inside the same process.

turbo-rails’ jobs inherit from ActiveJob::Base, not from your ApplicationJob. Your retry policy, your queue and your callbacks do not apply. All three jobs do discard_on ActiveJob::DeserializationError: a record deleted between enqueue and execution makes the broadcast vanish, with no retry and no error.

Stimulus and third-party libraries

The lifecycle, precisely

Stimulus knows nothing about Turbo. It reacts to a MutationObserver, in the microtask that follows each modification.

On a classic visit, the order is: turbo:before-cache, then the <body> swap, so disconnect() on the old controllers and connect() on the new ones, then turbo:render and turbo:load. Note that disconnect() comes after turbo:before-cache: the snapshot is taken first. That is why turbo:before-cache was historically the right place to undo whatever a library had injected.

Under morph, there is neither turbo:before-cache nor disconnect/connect for elements modified in place. The callbacks only fire if the node is actually added or removed, if it is reparented through the pantry on the insertBefore path, or if the value of data-controller changes.

The recommended reconnection pattern comes, once again, from Turbo’s test fixtures:

addEventListener("turbo:morph-element", ({ target }) => {
  for (const { element, context } of application.controllers) {
    if (element === target) {
      context.disconnect()
      context.connect()
    }
  }
})

It is expensive on a large page, since it reconnects every controller of every morphed element, but it is the most official answer in existence.

What breaks, and why

The general rule: any library that injects DOM the server does not render, or that writes class or style at runtime, is incompatible with morphing without explicit protection.

Library What breaks The cause The fix
Chart.js, Chartkick The chart disappears, a “Loading…” stays behind Chartkick listens for turbo:before-render and destroys every chart, including when renderMethod is morph. The <script> that would recreate it does not re-execute Chartkick.config.autoDestroy = false, then redraw on turbo:morph
Alpine.js Elements become invisible again, the :class bindings drop Alpine removes x-cloak on init and writes class and style.display at runtime. The server HTML knows nothing about them, the morph puts them back Cancel turbo:before-morph-attribute for x-cloak, class and style on [x-data] subtrees
Tom Select, Select2, Choices The widget disappears, the selection reverts The injected <div> is not in the server HTML, so it is removed. The original <select> survives, so there is no reconnection data-turbo-permanent with an id, or destroy and reinitialize on turbo:morph-element
Leaflet, Mapbox GL Dead map, gray tiles, or Map container is already initialized The injected panes are removed, the container survives map.remove() in disconnect(), invalidateSize() after reconnection
<dialog> opened with showModal() The page becomes unusable The morph rewrites the content but does not reset the browser’s top layer Open, unresolved ticket. Close the dialog on turbo:before-render, or exclude it from the morph
<details> Panels open or close on their own for every viewer open is synced like an ordinary attribute Cancel turbo:before-morph-attribute for attributeName === "open"
<turbo-cable-stream-source> Broadcasts are lost If it is reparented on the insertBefore path, it unsubscribes then resubscribes Give it a stable id and move it out of reordered areas

Trix and Action Text have been fixed since March 2025, contrary to what many blog posts still claim. The technique used is worth knowing, because it is the best general pattern for a custom element under morph: <trix-editor> sets a connected attribute on initialization and declares it in observedAttributes. The server HTML does not contain it, so the morph removes it, attributeChangedCallback fires, and the element reinitializes itself.

It is a self-healing custom element. If you write your own, do that.

What leaks if you do not clean up

Turbo turns your application into a long-running process. Everything you create in connect() must be torn down in disconnect(), without exception: the setInterval timers, the addEventListener calls on window or document, the IntersectionObserver and ResizeObserver instances (which keep the observed node alive and block collection of the entire detached subtree), the Action Cable subscriptions, the Chart.js instances.

The case of WebGL contexts is worth citing: browsers cap them at around sixteen. One Mapbox map left undestroyed, sixteen navigations, and all your canvases go blank.

Injecting a stream without an HTTP response

Turbo.renderStreamMessage() accepts an HTML string containing <turbo-stream> elements and applies them as if they had arrived over the network. The <script> tags inside the <template> are activated, permanent elements are preserved, focus is restored by id.

This is what you need for: a transport other than Action Cable (raw WebSocket, SSE, postMessage from a service worker), an optimistic update synthesized on the client before the server confirms, or a native shell pushing HTML into the webview.

Turbo.renderStreamMessage(
  `<turbo-stream action="append" target="messages"><template>…</template></turbo-stream>`
)

Network and offline

When the request fails, nothing happens

Turbo dispatches turbo:fetch-request-error, which bubbles and crosses shadow roots, with detail.request and detail.error. A listener placed on document catches it reliably.

What happens next depends on the context, and the difference is brutal.

For a frame or a form submission, the user sees nothing. No banner, no toast. A console.error, and the frame stays on its old content.

For a Drive visit, Turbo reloads the page completely. The network failure is recorded as SystemStatusCode.networkFailure, the adapter dispatches turbo:reload with reason: "request_failed" and does window.location.href = …. Offline, that means: the browser’s network error page, and your application destroyed. It is the worst possible moment to lose client state.

That detail changes the nature of the listener below. The preventDefault() is not cosmetic: it makes Turbo’s internal guard return false, which short-circuits the error handling and therefore the reload.

document.addEventListener("turbo:fetch-request-error", (event) => {
  event.preventDefault()   // also prevents the full reload on a Drive visit
  showOfflineBanner(event.detail.error)
})
window.addEventListener("offline", () => showOfflineBanner())
window.addEventListener("online",  () => hideOfflineBanner())

Watch out for an ordering trap: turbo:before-fetch-response fires only if there is a response. Network-loss detection built on it will never fire when the network is actually down.

Turbo and service workers

Turbo navigations are window.fetch() calls. Turbo passes no mode option, so on the service worker side the request has mode === "cors" and, more surprisingly, destination === "", the default value for anything coming out of fetch().

A service worker that routes on request.mode === 'navigate' therefore misses every Drive navigation. And the fix you read everywhere, adding || request.destination === 'document', catches nothing at all: that destination only exists for navigations initiated by the browser itself. You have to discriminate on what Turbo actually sends:

const isDocumentNavigation = ({ request }) =>
  request.method === 'GET' && (
    request.mode === 'navigate' ||                                   // browser navigation
    (request.destination === '' &&                                   // Turbo's fetch()
     (request.headers.get('Accept') || '').includes('text/html'))
  )

registerRoute(isDocumentNavigation, new NetworkFirst())
registerRoute(
  ({ request }) => ['style', 'script', 'image', 'font'].includes(request.destination),
  new CacheFirst()
)

The second route works as is: those requests really are initiated by the browser and carry a populated destination.

Two ways to break Turbo from a service worker:

Serving a response with the wrong Content-Type. Turbo records contentTypeMismatch and abandons the visit silently.

Serving HTML whose asset fingerprints no longer match. Turbo compares the data-turbo-track="reload" elements between snapshots. On divergence, it triggers a full browser reload, which the service worker answers again from the cache. You have a reload loop.

Three constraints specific to Rails 8:

An official patch is in progress (turbo#1427, a Turbo.offline API shipped in a separate bundle) but it is open, not merged. Do not write an architecture that depends on it.

Action Cable replays nothing

Timeline showing broadcasts lost during a WebSocket outage, with no error and no log, and the callout on the connected attribute as the only signal.
The only signal available is an attribute on an element. There is no event, no sequence number, no catch-up.

This is, in my view, the least documented production risk in all of Hotwire.

Action Cable is publish/subscribe with no persistence, no acknowledgement, no sequence number and no history. A message published while a consumer is disconnected is delivered to those who are subscribed at that instant, then thrown away. There is nothing in the protocol capable of replaying it.

Action Cable reconnects on its own, and the tab receives the subsequent messages again. Everything that went through during the outage is permanently lost. The page is then silently, indefinitely stale. No error, no indication, nothing.

Wifi switching over, a computer waking up, a tunnel, a deploy: this is not an edge case, it is the daily life of a mobile user.

The entire observation surface fits in one attribute: <turbo-cable-stream-source> sets and removes connected. No event is dispatched. Hence the workaround, which is application code:

// Stimulus controller placed on <turbo-cable-stream-source>
connect() {
  this.observer = new MutationObserver(() => {
    const isConnected = this.element.hasAttribute("connected")
    if (isConnected && this.wasDisconnected) {
      Turbo.visit(location.href, { action: "replace" })
    }
    this.wasDisconnected = !isConnected
  })
  this.observer.observe(this.element, { attributeFilter: ["connected"] })
}

disconnect() { this.observer.disconnect() }

Handle visibilitychange (the machine waking up) and window.online too.

With turbo_refreshes_with method: :morph, scroll: :preserve, this catch-up costs one round trip and preserves the scroll and the focus. This is the best practical argument in favor of pairing morphing with broadcasts: the catch-up becomes cheap enough that you actually write it.

One last thing to know in the same vein: Session#refresh also drops a refresh that arrives while a navigation is already in flight (!this.navigator.currentVisit), with no retry. It is a second, narrower path to the same silent staleness.

The decision tree

Four cascading questions: does the URL have to change, a single zone with its own URL, several zones or a server-driven update, purely client state. Leading respectively to Drive, Frame, Streams, Stimulus.
The first yes gives you the tool. Going one step further down always costs more than stopping.

The test that settles it for frames is the URL test: if there is no URL that renders this fragment on its own, a frame is not what you want. A frame is a mini browser, with an address, a loading state and a history. Without an address of its own, it brings you nothing and costs you the identifier constraint.

The test that settles it for streams is the origin test: if nobody asked for anything, it is a broadcast. If someone has just clicked and several zones have to change, it is a .turbo_stream response. These are two very different uses that share one format.

The symptom index

Symptom Most likely cause
The form goes out, nothing moves, console: “Form responses must redirect” 200 with HTML on a POST. Answer 422 on failure, 303 on success
The form goes out, nothing moves, no error at all A data-turbo="false" ancestor, or the form is inside a frame you had not noticed
The frame empties out, “Content missing” The response has no <turbo-frame> with the same id. Often a redirect to /login. Mark the target page with turbo_page_requires_reload
The stream arrives, visible in the network tab, no effect The target does not exist in the DOM. It is a completely silent no-op
The stream does not arrive on a GET link Add data-turbo-stream: without it the Accept header is not sent
first child element must be a <template> element A <turbo-stream> built by hand without its <template>
The Content-Type is wrong and nothing at all happens Turbo tests startsWith("text/vnd.turbo-stream.html"). Otherwise it does not intercept, and the response goes back into normal handling, with no error
The JS stops working after a navigation Initialization on DOMContentLoaded, which only fires on the first load. Move to turbo:load or to Stimulus
The JS stops only since morphing was turned on Inline <script> tags do not re-execute, and turbo:before-cache no longer fires
Morphing “does not work”, the page gets replaced <%= yield :head %> missing from the layout: turbo_refreshes_with writes nothing without it
Morphing does not trigger after a form The request has to leave from and land on the same URL
Full browser reload on every navigation data-turbo-track="reload" signature mismatch. Normal after a deploy. In a loop, look for a dynamically injected asset or a service worker
Lists animate all over the place under morph No stable id on the items, or an id duplicated elsewhere in the document, or the tag changed
Works in Chrome, breaks in Safari, under morph moveBefore versus insertBefore in idiomorph’s pantry. It is not your code
The text the user is typing gets erased syncInputValue writes the live properties, and Turbo does not enable ignoreActiveValue
The back button shows stale content Intended behavior: the cached snapshot is rendered first. turbo-cache-control: no-cache if the content is sensitive
The back button shows a duplicated widget The snapshot kept the DOM injected by the widget. Destroy it in disconnect(), not in turbo:before-cache if you morph
Flash messages do not appear under morph A refresh broadcast carries no flash. And if the flash has the same id and the same text, the morph changes nothing at all
A broadcast updates the wrong user’s page Stream name not partitioned. Signing is not authorizing
A broadcast arrives twice for its author Synchronous variant with no request_id, or broadcast from a job
Real-time updates stop, with no error The WebSocket dropped. Action Cable replays nothing. Catch up on the connected attribute
Nothing happens when the network is down, in a frame or a form turbo:fetch-request-error is dispatched but nobody is listening
The browser error page appears when the network is down, on a link click A Drive visit failing on the network triggers a full reload. preventDefault() on turbo:fetch-request-error prevents it
Changes are never broadcast from a rails runner The process ends before the debounce task scheduled 0.5 s out

What changed recently

If you are rereading documentation or blog posts written before 2026, be wary of these points, all changed in version 8.0.21 of January 2026:

And a few things present in the code but absent from the official reference, worth knowing: the turbo:before-frame-morph event, the Turbo.config.forms.mode, Turbo.config.forms.submitter, Turbo.config.drive.enabled and Turbo.config.drive.unvisitableExtensions options, and the fact that turbo:frame-render is declared cancelable even though canceling it produces no effect (the return value is discarded).

Finally, the reference versions for this article are pinned to Turbo 8.0.23 and turbo-rails 2.0.23. Almost everything above was verified by reading the code of those two versions rather than the documentation, precisely because that is where the gaps are.