Turbo’s documentation has a reputation for being incomplete, although each page says roughly what it should; the problem is that Drive, Frames and Streams are documented side by side as separate products, without explaining what connects them or how to choose. You learn the syntax of each one and remain stuck on the question that matters when writing code: which scope fits this change?

When choosing, I look at which part of the document changes and when its target is decided:

These are the three mutation scopes used throughout the article: the whole page, a named fragment, or a set of elements designated by a response.

remove, refresh, custom actions and broadcasts are not literally replacements, but the model still holds. Morphing changes how the mutation is applied, not its scope.

This is the guide I wanted three years ago. Use the model and decision table to choose a primitive, and the checklist and symptom index to debug one. The rest documents Turbo 8.0.23.

Versions and confidence levels

This article targets Turbo 8.0.23 and turbo-rails 2.0.23, released on 29 January 2026, with Rails 8. Most details below come from the source and tests for those pinned versions. Callouts that depend on a particular guarantee use the following labels:

Links to Turbo and turbo-rails source code point at the v8.0.23 and v2.0.23 tags, so at frozen lines: they will still say what I claim they say long after main has moved on.

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 before the request leaves, and Turbo Streams, which applies commands to targets named by the server in the response.
The same mechanism, three scopes. What changes from one column to the next: the extent of the replacement, who designates its target, and when.

With Drive, the target is implicit: it is the <body>. It is never negotiated, so there is no identifier contract to honour. 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. On the normal extraction path, Turbo accepts only the element carrying that same identifier, either directly or through a recurse frame. The server cannot redirect the content into another frame. It can only bypass extraction with turbo-visit-control: reload, which turns the response into a full-page visit.

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

Remember

Frames and Streams both rely on a DOM contract. The normal frame extraction path must find the expected identifier, either directly or through recurse; otherwise Turbo dispatches turbo:frame-missing, displays Content missing unless the event is canceled, and throws. turbo-visit-control: reload bypasses that path entirely with a full-page visit. A stream target must exist when the command arrives; if it does not, Turbo silently does nothing.

Which primitive should you reach for?

The table below is the short version. It does not rank the tools from simplest to most advanced. Start with where the state originates, then pick the first row that describes the actual need.

What you need The primitive Why this choice
The state is purely client-side and the server has no business knowing about it Stimulus, without Turbo A round trip to open a menu is one round trip too many
The whole page changes and the URL must remain shareable Drive, meaning nothing to write It is already on. Reach for a frame only when the navigation belongs to one named region; frames can participate in history with data-turbo-action
One named zone changes, and a URL returns a document containing that frame Frame Lazy loading, loading state and internal navigation come for free. The response may be a full page; Turbo extracts the matching frame. A stream would make you write the same thing by hand
Several non-contiguous zones change in response to a user action A .turbo_stream response A frame can only aim at one fragment. Slicing the page into five frames to fake a stream multiplies the requests
The change comes from the server, with nobody clicking in this tab A broadcast It is the only path that can arrive without an originating request in the tab. When Turbo renders the broadcast HTML itself, its synthetic renderer has no browser session or usable current_user

That leaves the case where both look possible.

The URL test, for frames. If there is no navigable URL whose response contains the matching frame, a frame is probably not what you want. That response does not have to be a fragment-only endpoint: a full document containing the expected <turbo-frame> is the normal case. A frame is a small browser with an address and a loading state, and it can also promote its navigations into Drive history with data-turbo-action.

The recipient test, for streams. If only the tab that just acted needs to change, use a .turbo_stream response. If other tabs must receive the mutation without having made that request, use a broadcast. These are two very different uses sharing one format, and confusing them is a common source of HTML broadcast to the wrong user.

Turbo Drive

Drive is active as soon as you load @hotwired/turbo. There is nothing to write: despite the documentation’s chapter order, Drive covers most navigation needs.

What is intercepted, and what is not

Drive intercepts unmodified primary clicks on navigatable <a href> elements and navigatable form submissions, provided their destination is visitable. A visitable URL stays under the page’s <meta name="turbo-root"> (/ by default) and its extension does not appear in Turbo.config.drive.unvisitableExtensions (about fifty extensions, including .pdf, .zip, .csv, .jpg). Cross-origin URLs are therefore excluded with the default turbo-root. The extension list is configurable and is documented nowhere on the official site.

Turbo also leaves download links, every link whose target differs from _self, and forms with method="dialog" to the browser.

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

There is no universal sequence. This table follows an advance link navigation that opens a Drive visit with a fetch, assuming a cacheable departure document and no preview snapshot. A full-page form submission makes its own request first, while a restoration from cache skips the fetch events; on the initial page load, turbo:load fires without turbo:render, and render hooks may run twice for a preview. The last column remains the useful one because several names are misleading.

Event Fired on Cancelable What it actually lets you do
turbo:click the clicked <a> Cancel to let the browser do a plain navigation
turbo:before-visit <html> The last place you can refuse the visit
turbo:before-fetch-request <html> on a visit, the frame or form involved, the <a> on a prefetch or preload preventDefault() does not block the request, it pauses it until you call detail.resume()
turbo:visit <html> Informational. detail.action is advance, replace or restore
turbo:before-fetch-response <html> on a visit, the frame or form involved, the <a> on a prefetch or preload Canceling stops the Visit/Frame/FormSubmission delegate from handling the response. It does not block a turbo-stream by itself: StreamObserver ignores defaultPrevented and may apply it from the same event
turbo:before-cache <html> Clean the DOM before caching. Cloning is deferred until the next event-loop tick
turbo:before-render <html> Same pause semantics. detail.newBody can be modified before rendering, detail.renderMethod is replace or morph
turbo:render <html> The new body is in place
turbo:load <html> End of the visit. On the initial load, Turbo fires it when readystatechange reaches interactive or complete; an async bundle loaded after DOMContentLoaded can start too late and miss it

First surprise, turbo:before-fetch-request comes before turbo:visit. Visit#start() calls this.adapter.visitStarted(this), which prepares the request, before this.delegate.visitStarted(this), which dispatches turbo:visit (visit.js#L114-L118). The actual fetch() still waits for interception to finish, but a listener installed from turbo:visit has already missed turbo:before-fetch-request. Listen to that event directly if you need to observe or modify every request.

The other surprise comes down to a dispatch() default: with no explicit target, it fires on document.documentElement, not on document (util.js#L29-L44). The events bubble, so listening on document works, but event.target is <html>, which matters if you filter on it. This is true for both fetch events of a Drive visit. A frame fetch targets the frame, a form submission targets the <form>, and a prefetch or preload targets the <a>.

And the events specific to frames and forms, which slot into the same sequence:

Event Fired on Cancelable What it actually lets you do
turbo:before-frame-render the frame Pause, and detail.render is replaceable: this is the official entry point for plugging in another rendering engine
turbo:frame-render the frame Declared cancelable: true, but the dispatch return value is discarded: canceling does nothing
turbo:frame-load the frame The frame is done
turbo:submit-start the <form> detail.formSubmission
turbo:submit-end the <form> Always detail.formSubmission. A response handled by FormSubmission adds success and fetchResponse; a network error adds success: false and error. Those keys are absent after an abort and after the Form responses must redirect guard rejects a non-redirected unsafe 200 response.

Under the hood Verified

turbo:before-fetch-request does one more thing than its name suggests. Pausing it is documented in the handbook, but not the fact that Turbo re-reads the URL afterwards: this.url = event.detail.url (fetch_request.js#L183-L199), and that this.url is exactly what goes into the fetch a moment later. Rewriting event.detail.url from a listener therefore really does change the URL that gets called, which is the shortest way to add a tenant prefix to every Turbo request at once.

You still have to do it synchronously in the listener: the assignment happens before the pause’s await, so changing detail.url after a preventDefault() and before resume() has no effect. And assign a URL object, not a string: Turbo reads this.url.href, and a string gives undefined. It is not in the handbook, but it is covered by the repository’s own test suite (form_submission_tests.js#L191-L204), which makes it a sturdier guarantee than it looks.

The rest of the lifecycle bugs almost always come from the snapshot or the preview.

The snapshot comes from the page you are leaving, not the one you are loading. turbo:before-cache fires on the live document, then PageView#cacheSnapshot() waits until the next event-loop tick before calling cloneNode(true). Synchronous cleanup in this hook remains the deterministic path. The visit does not await cacheSnapshot(), however, so replacing the <body> and a Stimulus disconnect() can change the old DOM before it is cloned. A disconnect() is not necessarily too late to clean the cache.

The clone loses the listeners but keeps whatever injected DOM still exists at that point. That is how a library can end up initialized twice on back navigation; we will return to it later.

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 can fire turbo:render twice, but not on back navigation. On an advance or replace visit, Turbo uses a cached snapshot only when it is previewable and, when the URL has an anchor, contains that anchor. It renders the snapshot while the fetch continues; if the fresh response is itself rendered, a second turbo:render follows. During the first render, <html> carries the data-turbo-preview attribute.

The nuance matters, and I had it wrong for a long time. A restoration visit with a usable snapshot issues no request and renders once. Without a usable snapshot, it goes back to the network. advance and replace describe history actions, not just link clicks or form submissions: Turbo.visit() and a page refresh can produce them too.

Under the hood internal

It all comes down to a six-line method: shouldIssueRequest() returns !this.hasCachedSnapshot() when this.action == "restore", and this.willRender otherwise (visit.js#L377-L383). And loadCachedSnapshot computes its preview as const isPreview = this.shouldIssueRequest() (visit.js#L245-L265). No request, so no preview, so a single render.

In practice, the guard to write in a Stimulus controller is unchanged:

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 and on reload. Each entry keeps a clone of the <body> and an index of the <head>, including <img>, <link>, and <script> elements. It does not store the bytes of images, stylesheets, or scripts; reusing those is the browser’s HTTP cache job.

Remember

Turbo’s cache is a perceived-latency optimization, not an offline strategy. A restoration visit can display an existing snapshot without a request after the network drops, but the cache survives neither a reload nor a closed tab and cannot guarantee that the page you need is still present. Do not use it as a persistence layer.

You often read that the cache is emptied “on every unsafe form submission”. That is true for half the cases only, and the other half is surprising.

Under the hood internal

The clearing is asymmetric. On the full-page success path, clearSnapshotCache() is only called when !formSubmission.isSafe, and only inside the if (responseHTML) branch: an unsafe submission whose response is not HTML clears nothing (navigator.js#L71-L90). On the full-page failure path, the cache is cleared when responseHTML exists, including for a safe GET form returning an HTML 4xx or 5xx (navigator.js#L92-L107). Inside a frame, the form-submission callbacks differ: an unsafe success and every failed HTTP response clear the cache even without HTML (frame_controller.js#L246-L260). A network error goes through formSubmissionErrored() and does not clear it; neither does a failed link or src navigation.

Concretely, a GET search form clears the entire cache if it returns an HTML 4xx or 5xx as a full-page submission, or any 4xx or 5xx inside a frame. Nothing tells you.

What is left for you to tune:

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) => window.confirm(message)

Under the hood Verified

Of those five lines, two are documented on the Drive reference page: Turbo.config.drive.progressBarDelay and Turbo.config.forms.confirm. drive.enabled, forms.mode and forms.submitter appear nowhere on turbo.hotwired.dev. The three modes are inferred from the two equality tests in Session#submissionIsNavigatable plus the "on" default; there is no enum in the source.

One detail that goes with it, and it bites: set submitter(value) { this.#submitter = submitter[value] || value } (config/forms.js#L33-L35). An unrecognized string is stored as-is, without error, and blows up later on config.forms.submitter.beforeSubmit(...). A typo only shows up on the first submission that has a submitter; a submission triggered without one never makes that call.

forms.submitter deserves a word. By default, Turbo sets disabled on the submitter during submission. The control then leaves the tab order and, if it had focus, loses it; depending on the browser and assistive technology, focus may fall back to <body> and disorient someone navigating by keyboard or screen reader. With "aria-disabled", Turbo sets aria-disabled="true" and intercepts clicks on the submitter, which remains focusable. That blocks a second click handled by Turbo, not a submission triggered directly from code.

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

If the interface should preserve focus during submission, this line in application.js is the better default. Just remember to style the aria-disabled state.

Prefetching

Since Turbo 8, prefetch on hover is on by default. For an eligible link, 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 by default. The turbo-prefetch-cache-time meta tag changes that TTL in milliseconds. Leaving the link before the delay expires cancels the start.

Not every link qualifies. It needs a same-origin, visitable HTTP(S) URL with no target, download, data-turbo="false", unsafe method, stream, confirmation, or UJS attribute; links to the current page are excluded too. turbo:before-prefetch can still cancel. Hovering any other eligible link does send a request to 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 only applies to <a> elements and which Turbo scans after the initial load and after every view render rather than on hover. It warms the same Drive snapshot cache; only hover prefetching has the separate one-entry, ten-second cache.

Turbo Frames

It all comes down to one identifier

The request carries the Turbo-Frame header. The response can be any HTML: Turbo first applies turbo-visit-control, then looks for the frame directly or through recurse. If no frame matches, it dispatches turbo:frame-missing.
After turbo-visit-control, Turbo looks for the frame directly or through recurse. If no frame matches, it dispatches turbo:frame-missing.

The matching rule, literally, is container.querySelector("turbo-frame#" + CSS.escape(id)). No fuzzy matching, no configurable selector. In practice, have the server echo the identifier sent by the client with 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 breaks every later navigation to that frame, without a word.

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. The ~= syntax belongs to Turbo’s internal CSS selector, not to the HTML attribute.

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.

Session expiry gives a common example: the request leaves from the frame, the server redirects to /login, and the login page obviously does not contain your frame. Rather than intercepting the event, 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 parses the response outside the live DOM and extracts the frame, but you pay the server-rendering, network and parsing cost of the entire layout on every frame request. The discarded <head> scripts are not evaluated. Scripts inside the extracted frame are activated during rendering, except those marked data-turbo-eval="false". 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

Trap

No error, no warning, and the frame renders perfectly. Just a silent server, network and parsing bill on every request. The response’s <head> is parsed and discarded, not executed. This is the kind of regression that only shows up when you profile.

The attributes that matter

Attribute Effect
src Loads this URL into the frame. In the turbo_frame_tag helper, the value goes through url_for, so a model works.
loading="lazy" Defers the first load of src until the frame enters the viewport. Within one FrameController, later src changes load immediately. After a Drive restoration the controller is recreated and that internal flag starts over, so a new src outside the viewport can wait again.
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 rendering a matching frame, but also before turbo:frame-missing. On a connected frame, a src change or reload() removes it. The JS property frame.complete reports the current loading state and does not read this attribute.
disabled Cancels the current src fetch and stops the frame from intercepting new navigations. A descendant link or form can then fall through to Drive and navigate the full page; a form submission already in flight is not canceled.
autoscroll Calls scrollIntoView() on the first element child after rendering; an empty frame does not move. data-autoscroll-block defaults to end and data-autoscroll-behavior to auto.
refresh="morph" Morphs the contents fetched by reload(). During a page refresh rendered with morphing, Turbo keeps a compatible src frame outside a data-turbo-permanent region and calls that reload automatically.

The table does not say everything about how a frame handles a response.

For a link or src navigation, once an ordinary HTML response reaches FrameController, HTTP status does not choose the frame renderer. The direct requestSucceededWithResponse and requestFailedWithResponse callbacks both call loadResponse(). A 4xx or 5xx with non-empty HTML and a matching frame can therefore render like a 2xx. A turbo-stream Content-Type is intercepted earlier by StreamObserver. Other non-HTML Content-Types and empty bodies produce no frame render and no turbo:frame-render, turbo:frame-load, or turbo:frame-missing, although turbo:before-fetch-response has already fired. Canceling that event blocks the FrameController path, with the stream exception described in the table. With turbo-visit-control: reload, Turbo abandons extraction and starts a full-page visit with a console warning; without a matching frame, it follows the turbo:frame-missing path above.

An ordinary HTML form submission takes a different path. Unless turbo:before-fetch-response is canceled, a 4xx or 5xx puts detail.success === false in turbo:submit-end, clears the snapshot cache, and calls loadResponse() on the originating frame. If the form targeted another frame, that target is therefore ignored for the failure and Turbo looks for the originating frame’s id in the response. A success uses the resolved target instead, and only clears the cache for an unsafe method.

Under the hood internal

One clarification, because “the status is never consulted” is slightly wrong. loadResponse does read two status-derived properties, but for one thing only: updating src.

if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) {
  this.sourceURL = fetchResponse.response.url
}

(frame_controller.js#L132-L135) After a link navigation or direct src assignment, the frame already points at the requested URL: if it returns an HTML 404, reload() repeats that failing URL. A form submission does not copy its action into src. On a non-redirected failure, the originating frame therefore keeps its previous src, if it had one, and reload() may return there; with no src, it loads nothing. After a redirect, src always takes the final URL even when its status is a failure.

A form submission inside a frame does not need to redirect. The Form responses must redirect to another location error is triggered only for an unsafe full-page submission whose final response is exactly 200 OK without a redirect. GET submissions do not have that constraint, and the guard does not cover other 2xx statuses. Inside a frame, mustRedirect is false: a 200 OK with a matching frame is valid.

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 causes a fair share of Turbo Stream bugs.

Two columns comparing an HTTP turbo-stream response rendered inside the request cycle and a deferred broadcast rendered outside a request, where Devise raises MissingWarden if the partial calls current_user. Both converge on the same turbo-stream element.
The format is identical and the tab cannot tell them apart. The whole difference was settled earlier, when the server rendered.

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

The second is a broadcast: the server publishes on an Action Cable channel, and every subscribed tab receives it. The model macros use jobs for operations that render HTML, but turbo-rails also exposes synchronous broadcast methods. When turbo-rails has to render the HTML, its synthetic renderer has no browser session or usable current_user. A synchronous call, perform_now, or the :inline adapter may still see thread-local state such as Current.*; a job actually dequeued by a worker does not inherit it. A call that already receives html: or content: skips that renderer, and a refresh renders no HTML. When turbo-rails renders the partial, it produces one HTML payload and sends it unchanged to every subscriber. We come back to it below, because that is where the real problems hide.

Remember

The format says nothing about the context. A <turbo-stream> received by a tab carries no trace of where it came from, and the application code is the same in both cases. Everything that separates the two worlds happened server-side, at render time, which is to say at the moment you decide what goes inside the <template>.

Frames or Streams? What really separates them

Both can replace a piece of a page, and that is where the confusion starts.

Criterion Turbo Frame Turbo Stream
Who designates the target The client, before the request leaves For a targeted action, the server, in the response
How many zones One, the frame’s own Per <turbo-stream>: zero or one with target, every match with targets. A response can contain several elements
Is the mutation a navigation Yes: a URL returns the expected frame No: the stream describes a mutation, even when its request has a URL
History and back button Optional, with data-turbo-action No
Lazy loading Yes, loading="lazy" No
Can it fire without a request No Yes, that is the broadcast
What happens when the target is missing On the direct path, turbo:frame-missing, then “Content missing” by default; recurse may still load the expected frame, and turbo-visit-control: reload bypasses extraction For a targeted action, nothing at all, silently
What the server must know The expected identifier, usually echoed from the Turbo-Frame header For a targeted action, the id through target or the CSS selector through targets. refresh has no target

The last row is the one that should decide. On the normal direct path, a frame limits the contract to one matching fragment: the server may render a full page, but it must contain the requested identifier. recurse can find it in a second response, while turbo-visit-control: reload abandons that contract for a full-page visit. A targeted stream action is more coupled: the server must know how to address its targets in the live DOM, with no way to verify that contract. refresh is the exception because it names no target.

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 is format and vocabulary: the server returns HTML instead of arbitrary application JavaScript, and Turbo ships eight built-in actions. Custom actions extend that vocabulary explicitly, rather than opening the door to “everything jQuery knows how to do”. The main gains are readable responses and bounded intent. That is not a security boundary: Turbo deliberately activates <script> elements inside stream templates, so untrusted HTML is still untrusted HTML.

The full path, from controller to view

A conventional controller looks like this:

# 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

A bare format.turbo_stream renders a template; use it when several regions change. Inline render turbo_stream: also accepts several concatenated tags, but its main convenience is avoiding a file for simple responses.

The template is a plain ERB file that uses the stream 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 %>

Those three lines condense almost the whole API, and each one hides something.

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 it, record-based helpers cannot target the element directly later.

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 the current view context. In an HTTP response it therefore sees the controller ivars, including those set by before_action hooks. The synthetic context described above applies only when rendering a broadcast.

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

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 Refreshes the current URL through a replace visit. 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 simply ignores it. refresh reads method too, but for another reason: to choose the render mode of the page refresh there, broadcast by broadcast.

The reference documents how 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. Since 8.0.21, before and after apply the same rule to the target’s siblings; that extension is not yet in the reference.

refresh also carries a request-id attribute. It lets the originating tab ignore a recent request it has already applied; the client-side debounce that merges closely spaced refreshes is independent of that identifier. The broadcasts section covers both mechanisms.

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 #

The trap appears when 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

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.

Debug internal

Total silence only applies to a target that is present but not found. If the attribute itself is missing, get targetElements() throws "target or targets attribute is missing", and get performAction() throws "unknown action" or "action attribute is missing" (stream_element.js#L95-L120). Those throws are caught by connectedCallback’s try/catch and surface as console.error (stream_element.js#L33-L41), visible, but with no event and nothing on screen.

Check the console first: silence despite a received stream points to a missing target; a console.error points to a missing attribute or an unknown action.

There is a second one, 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, normal content negotiation falls back to HTML, unless the URL or params explicitly force the turbo_stream format.

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

Turbo does not choose a renderer by reading the request headers on the way back. The fetch delegate is known before the request leaves: FrameController for frame navigation, Visit or FormSubmission for full-page navigation. The response then follows this path:

  1. If the Content-Type starts with text/vnd.turbo-stream.html, StreamObserver intercepts the response and applies the <turbo-stream> elements, regardless of status.
  2. Otherwise the response goes back to that delegate. The Turbo-Frame header tells the server which fragment to render; Turbo does not use it as a client-side response switch.
  3. On the full-page path, status then helps choose the normal renderer, the error renderer, or a refusal to render.

A Hotwire form that appears frozen often comes down to one line of Turbo’s code:

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

For a full-page non-GET submission, if you answer with 200, 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. A submission inside a frame does not pass through this guard.

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 the unambiguous way to say that the redirect target must be fetched with GET. Turbo passes redirect: "follow" and lets the browser follow. Under the Fetch specification:

There is an important turbo-rails detail here. In version 2.0.23, encodeMethodIntoRequestBody turns methods other than GET into a POST with an _method parameter before Fetch sees them (fetch_requests.js#L1-L18); Rails’ button_to method: :delete also submits a POST with a hidden _method. Fetch therefore sees POST, and a 302 is followed as GET in this stack. The often-repeated story in which a Turbo DELETE is replayed against the redirect target does not describe turbo-rails 2.0.23. status: :see_other remains the clearest and most portable convention, especially for clients that do send a real DELETE, but it is not repairing that particular failure mode here.

A turbo-stream response short-circuits renderer selection. render turbo_stream: …, status: :unprocessable_entity is therefore applied without trouble. The status remains observable: on a submission, turbo:submit-end.detail.success is false for a 422 and true for a 2xx. It also retains its meaning for tests and non-Turbo clients.

A small amusing detail: the test is on statusCode == 200 exactly. A 201 Created bypasses the guard and, when the response contains a non-empty HTML body, carries on to a visit. Without that body, Navigator proposes no visit.

4xx and 5xx: two rendering paths, not one

This is the point I had wrong, and it is repeated in plenty of articles: everyone writes that a 4xx is rendered by the normal page renderer and a 5xx goes through the error renderer. That is only true for form submissions.

What you did 2xx HTML 4xx HTML 5xx HTML
Submit a full-page form PageRenderer, except for an unredirected 200 from a non-GET method, which is refused PageRenderer ErrorRenderer
Click a link, or Turbo.visit() PageRenderer ErrorRenderer ErrorRenderer

The table assumes a usable HTML response. For a successful submission, Navigator only proposes the visit when responseHTML contains something, so an empty or non-HTML response never reaches PageRenderer. On a visit, a non-HTML response takes the error path.

Under the hood internal

On a visit, the test is binary: if (isSuccessful(statusCode) && responseHTML != null) renders normally, else goes to this.view.renderError(...) (visit.js#L203-L223), with isSuccessful defined as statusCode >= 200 && statusCode < 300 (visit.js#L417-L419). The 4xx/5xx distinction exists only in Navigator#formSubmissionFailedWithResponse (navigator.js#L92-L107).

That deserves more than a footnote, because ErrorRenderer is markedly more brutal than PageRenderer:

And symmetrically, since a form 4xx goes through PageRenderer, it is subject to shouldRender: a data-turbo-track="reload" mismatch on your error page will turn displaying validation errors into a full browser reload.

Trap

If your 500 page is a minimal template that does not load the same assets as the rest of the site, then every server error on a link click replays the <head> scripts without data-turbo-eval="false" and throws away your permanent elements. User reports then read like “the app goes weird after an error”, which is a horrible symptom to reproduce.

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. Scroll can be preserved; focus, text selection and transition state survive when the corresponding live node stays in place.

Enable it 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 page morph only happens for a page refresh. When PageView receives a Visit, the exact test comes down to two clauses: same pathname (the query string and the fragment do not count) and action === "replace". With no Visit, isPageRefresh() returns true directly; a 4xx response to a full-page form submission takes that path and may morph when the effective method is morph.

Without an explicit action, an unsafe submission only takes replace when the response redirects to the exact URL of the page that contained the form. Turbo compares the final URL with history.location, not with the form action (navigator.js#L158-L166):

data-turbo-action="replace" on the form or its submitter can force that action. A GET form moving from /search?q=old to /search?q=new can therefore morph; only the pathname has to stay the same. The same rule applies to a data-turbo-action="replace" link from /invoices?page=2 to /invoices?page=3.

Two common triggers are a form that redirects back to the page it left and a <turbo-stream action="refresh"> broadcast.

What morphing changes, line by line

The beginning and the end of the cycle are identical. It is the middle that changes, and the middle is exactly where your code lives.

What happens Classic render Morph
turbo:before-cache when Turbo is about to cache the snapshot same
Snapshot cached according to the visit policy same
The <body> replaced wholesale compared node by node by idiomorph
Stimulus disconnect() / connect() on everything on nodes added, removed or moved; even moveBefore produces the mutations that Stimulus interprets as a disconnect followed by a reconnect
Inline <script> already present re-executed unless it has data-turbo-eval="false" never
Genuinely new <script> eligible for evaluation executed executed; data-turbo-eval="false" leaves it inert
data-turbo-permanent transplanted by Bardo, id required current node skipped when it already carries the attribute, no id required, Bardo never runs
Scroll after a page refresh preserved with scroll: :preserve, otherwise reset same
Focus and text selection lost by default; restored inside a matching permanent element kept when the live node survives; after replacement, restoration is limited to <input> and <textarea> elements with an id
autofocus during a page refresh honoured MorphingPageRenderer.shouldAutofocus is false
turbo:render then turbo:load fire fire

The first two rows depend on the visit path, not the renderer. After a successful unsafe form submission, Turbo clears the cache and starts a visit with shouldCacheSnapshot: false: no snapshot, therefore no turbo:before-cache. Session#refresh also passes that flag, with an LRU exception detailed below.

turbo:load does fire after a morph because a refresh is a real visit; inline <script> tags are what do not replay, as the repository’s own test suite asserts (page_refresh_tests.js#L33-L42).

turbo:before-cache is controlled by snapshot paths, not by the renderer. You read, and I wrote, that morphing simply removes the event. It is narrower than that. The distinction determines which cleanup code stops running.

Under the hood internal

Session#refresh hard-codes shouldCacheSnapshot: false (session.js#L108-L117). With no snapshot already cached for the current URL, a <turbo-stream action="refresh"> therefore does not emit turbo:before-cache. But BrowserAdapter#visitStarted() calls loadCachedSnapshot() first (browser_adapter.js#L21-L26). If the LRU already contains a previewable snapshot for that URL and the current document is cacheable, this path calls cacheSnapshot() without checking the flag and emits the event (visit.js#L245-L263, page_view.js#L43-L50). After a successful form submission, Navigator sets shouldCacheSnapshot from formSubmission.isSafe and clears the cache first for an unsafe method (navigator.js#L71-L87): a POST that redirects to the same URL can therefore morph without emitting the event. A cacheable regular replace visit or GET form caches the snapshot and emits it.

So: do not wire teardown required by the live DOM to turbo:before-cache on the assumption that it precedes every morph. Keep that event for cleaning the snapshot when one will be taken, and use morph events or the component lifecycle to protect the current document.

A new script that is eligible for evaluation can run under morph. idiomorph matches the old <script> with the new one by tag name, then syncs their text nodes with oldNode.nodeValue = newNode.nodeValue. Changing the text node of an already executed script does not execute it again.

A <script> produced by DOMParser or a <template> is inert and does not run merely because it is inserted. Turbo replaces it with an element created through document.createElement("script") before rendering (util.js#L1-L15). That activation also runs on the morph path, before idiomorph compares the trees (page_renderer.js#L174-L190). If the morph finds a script already present, the live node stays in place and does not replay. If the script is genuinely new and does not carry data-turbo-eval="false", the activated element enters the DOM and runs.

idiomorph decides by identifiers

This is the central mechanism of morphing, and everything else depends on it: what survives a morph, what replays, what breaks in Safari and nowhere else.

Two columns showing the same insertion at the top of a list: with stable ids, one node is inserted and the others are untouched; without ids, matching happens by position and every row is rewritten. Below, the three conditions for an id to count, and the moveBefore versus insertBefore fork.
The content and visual order are identical in both columns; the new HTML is not. What changes is what survives the operation.

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 in either morph root removes that id from the persistent set, in both copies. For a page morph, the roots are the two <body> elements; for a stream morph, the search is limited to the target and the incoming content. A duplicate in <head> or outside the target does not affect that morph. A duplicate inside the same root can still degrade, at a distance, the matching of another element carrying that id.

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. The DOM nodes remain in place, and a running CSS transition continues when the attributes or styles that triggered it do not change. The actual bug: client state can stay attached to the wrong record, or be overwritten when idiomorph synchronizes the property carrying it.

Remember

Give every list item a stable, unique id, never reuse it, and never change the associated tag. dom_id(record) follows those three rules; without them, morphing a list becomes unpredictable.

Node moves, and why Safari differs

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

When idiomorph reuses a node with a persistent id elsewhere, it moves the node without cloning it. Depending on morph order, the move happens directly from the current tree or temporarily through the pantry, a hidden <div> after <body>. In both cases, idiomorph uses moveBefore() when available and falls back to insertBefore().

The choice between the two changes observable behavior:

I have seen this point misread more than once: describing moveBefore as “widely available in 2026” means excluding Apple.

Under the hood Verified

Per @mdn/browser-compat-data (snapshot of August 6, 2026): Chromium 133+, Firefox 144+, Opera 118, Samsung Internet 29. Safari, Safari iOS and the iOS WebView are all three at version_added: false, with WebKit bug 281223 still open.

Chrome and Firefox currently distributed on iPhone also use WebKit, so they take this fallback too. Apple does allow alternative browser engines in the European Union, which makes it too broad to equate all iOS traffic with WebKit forever. Safari, WebViews and the WebKit browsers in your traffic remain affected, as do Macs running Safari.

So yes, a morphing bug can be perfectly reproducible on an iPhone and nowhere to be found on your machine. It is not necessarily your code. And if your product is mostly consumed on mobile, this is not an edge case either: it may affect a material share of your users, which your own traffic data should settle.

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")
  }
}

Server-rendered HTML may contain a value attribute, as Rails form builders often emit one, or omit it. idiomorph clears the live value in the second case and replaces it with the server value in the first. Either way, a refresh arriving mid-keystroke can discard unsaved input. The same treatment applies to checked, disabled, <option selected> and the content of <textarea> elements.

Trap

This bug is easy to miss in single-tab development, but it is not production-specific. A page refresh during typing is enough; a second tab, a broadcast or Turbo.visit(location.href, { action: "replace" }) reproduces it locally. If you turn morphing on in an application where people enter data, treat field protection as a prerequisite, not as an improvement.

idiomorph has an ignoreActiveValue option that excludes document.activeElement from this synchronization. Turbo’s built-in renderers do not enable it and provide no setting for doing so. Turbo does export morphElements and morphChildren, which pass the option through to idiomorph, so a custom renderer can use it.

Without a custom renderer, it is on you to protect the field. The most direct lever is data-turbo-permanent, which skips the morph on the element; turbo:before-morph-element and turbo:before-morph-attribute allow narrower protections. Since you do not want to freeze the field permanently, you set the attribute on focus and remove it on the way out. Turbo’s test fixtures demonstrate the pattern for an <input>; this version extends it to <textarea>, <select> and contenteditable fields as well:

addEventListener("focusin", ({ target }) => {
  const field = target instanceof HTMLInputElement ||
    target instanceof HTMLTextAreaElement ||
    target instanceof HTMLSelectElement
      ? target
      : target instanceof HTMLElement && target.isContentEditable
        ? target.closest("[contenteditable]")
        : null

  if (field && !field.hasAttribute("data-turbo-permanent")) {
    field.toggleAttribute("data-turbo-permanent", true)

    const unprotect = ({ relatedTarget }) => {
      if (relatedTarget instanceof Node && field.contains(relatedTarget)) return

      field.removeEventListener("focusout", unprotect)
      field.toggleAttribute("data-turbo-permanent", false)
    }

    field.addEventListener("focusout", unprotect)
  }
})

As long as the live node survives the morph, it naturally keeps focus. If that node is replaced, idiomorph explicitly restores focus and selection only for an <input> or a <textarea> with an id. It offers no equivalent guarantee for a <select>, a contenteditable element or a field without an id.

During a page morph, autofocus is not honoured: MorphingPageRenderer declares shouldAutofocus as false. A frame morph takes another path; MorphingFrameRenderer inherits from FrameRenderer, which does apply autofocus after rendering.

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="map" data-turbo-permanent></div>

The documentation says nothing about what follows.

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 by Bardo. Under morph, it is different code with different rules.

Under the hood internal

Under morph, beforeNodeMorphed and beforeNodeAdded handle data-turbo-permanent with different criteria.

The “don’t touch me” guard (beforeNodeMorphed) tests the attribute on the current node: no id is needed, and the node is left alone when it already carries data-turbo-permanent. If only the incoming HTML adds the attribute, that first morph proceeds; later ones are frozen. The insertion guard does read the id, but with the opposite polarity: beforeNodeAdded = (node) => !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) (morphing.js#L61-L63). An incoming permanent element carrying an id that already exists in the document is therefore refused: the existing one wins. Without an id, it is inserted normally.

One more thing, and it explains a lot of surprises: MorphingPageRenderer and MorphingFrameRenderer both reduce Bardo to async preservingPermanentElements(callback) { return await callback() }. Under morph, Drive’s transplantation machinery does not run at all.

Under morph, a current node carrying data-turbo-permanent freezes its 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

Broadcast macros take one line. Their mistakes often appear only 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.

The code is intentional, but turbo_stream_from Card does not fix the asymmetry: the class produces the "Card" stream, not "cards". For an index to receive all three operations, use a _to macro and subscribe the page to that same stream. With the unsuffixed macros, it would have to subscribe to the collection stream for creates and to every record stream for updates and destroys.

Remember

If you want creation, update and deletion to all go to the same stream, use broadcasts_to for targeted DOM actions or broadcasts_refreshes_to for page refreshes. The unsuffixed broadcasts and broadcasts_refreshes send creates to the collection but updates and destroys to each record’s own stream. An index subscribed only to the collection therefore sees new rows appear, then nothing else move.

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 because it installs only one after_commit: everything there is asynchronous and debounced, including the destroy.

The full path of a refresh

The tab that writes receives its own broadcast, like every other tab. The whole request-identifier mechanism exists only to let it ignore the redundant refresh action.

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. A refresh created from that context re-emits it in the request-id attribute by default. 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 another network round trip and could lose scroll or focus, depending on the refresh configuration and which nodes the morph keeps.

This protection disappears, or acts at the wrong time, in the following cases:

  1. Outside a request, Turbo.current_request_id is nil: it is a thread_mattr_accessor populated by an around_action. Every tab refreshes. That is generally what you want from a background job.
  2. Without an X-Turbo-Request-Id header, for example from another HTTP client, the around_action has nothing to copy.
  3. The set is capped at 20. Twenty new Turbo requests after the one that triggered the broadcast are enough to evict its identifier. With prefetch on hover on by default, that is no longer so theoretical.
  4. Conversely, turbo_stream.refresh rendered as a direct response carries the request’s identifier by default, so the requesting tab ignores the action it has just received. Write turbo_stream.refresh(request_id: nil) if that response really should refresh the tab.

The synchronous broadcast_refresh_to variant is not an exception: it also goes through turbo_stream_refresh_tag, whose default is Turbo.current_request_id (action_helper.rb#L40-L46).

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 with the same key cancels the previous one. A thousand records modified in one request and broadcasting to the same stream therefore give one broadcast; a thousand distinct GID streams still give a thousand broadcasts.

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

The downside:

Trap

A process that exits before the delay expires loses the broadcast. A rails runner, a rake task or a container can send it just fine if it stays alive for more than 0.5 s. If it exits before the ScheduledTask runs, there is no error and no log. For a one-shot process, the synchronous broadcast_refresh_to avoids this timing. Adding sleep Turbo::Debouncer::DEFAULT_DELAY + 0.1 is only a workaround, not a documented contract.

This bug slips through code review easily: the migration script runs, the data is correct, and nobody notices that the open tabs did not move.

In Rails tests that load ActiveSupport::TestCase, there is no debounce. The turbo-rails initializer then replaces the debouncer with Turbo::ImmediateDebouncer. For N calls with the same key, your assertions count N broadcasts where production will see one. A test harness that never loads ActiveSupport::TestCase does not get this replacement automatically.

current_user is not available in a broadcast render

Every broadcast that has to render its own HTML, whether synchronous or asynchronous, goes through:

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

broadcast_refresh_later_to, along with calls that already receive html: or content:, has nothing to render and skips this renderer. The other paths use an ActionController::Renderer with a synthetic Rack environment, without a session, cookies or a Warden key, with the following 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 a practical reason to favor 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. Decoding the payload before -- reveals the JSON string "quotes" here. With a record, that string contains its to_gid_param, which can itself be decoded to gid://app/Account/5. Neither decode needs your key.

And Turbo::StreamsChannel#subscribed accepts the subscription with no additional check whatsoever. It verifies that the signature is valid, full stop.

Remember

Signing is not authorizing. A signed-stream-name proves the stream name came from your application; it proves nothing about the person subscribing to it, and it does not expire by default. Carrying the tenant inside the stream name prevents accidental cross-tenant broadcasts. It still does not authorize the subscriber: if access must be revocable, the channel has to check the current user when they subscribe.

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

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

That partitioning does not replace authorization. For revocable membership, write your own channel and perform the check before stream_from. Reusing an old signed name is only exploitable while that person’s Action Cable connection is still accepted; the channel check closes that door.

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. Retry policies, callbacks and queue_as declarations defined only on ApplicationJob therefore do not apply. All three classes declare discard_on ActiveJob::DeserializationError, but they do not carry the same arguments. With ActionBroadcastJob and BroadcastJob, a record present in the rendering options and deleted before execution makes the job discard without a retry; the instance helpers do add self to locals:. The stream and target have already been converted to strings before enqueue. BroadcastStreamJob, which handles refreshes, receives only the stream name and an already rendered <turbo-stream>, both as strings. Rails does not re-raise a deserialization error handled by discard_on, but it logs at error level and emits discard.active_job.

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 cacheable classic visit, turbo:before-cache starts the capture, but the clone is not immediate: PageView#cacheSnapshot waits until the next event-loop tick, and the visit does not await that Promise before continuing the render. The <body> swap and the MutationObserver microtask can therefore run disconnect() before the clone. Synchronous cleanup in disconnect() may clean the deferred copy too, but that ordering is not a contract. turbo:before-cache remains the public, deterministic hook for preparing the snapshot.

Under morph, elements merely modified in place get neither disconnect() nor connect(). Stimulus runs those callbacks for an addition, removal or move. A move can be direct or go through the pantry, including with moveBefore. Changing the value of data-controller triggers them too. turbo:before-cache is a separate question: it is absent after a successful unsafe form submission, and absent on Session#refresh unless the LRU already contains a previewable snapshot for the current URL and the current document is cacheable.

A Turbo 8.0.23 test fixture forces reconnection like this:

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

This is not a reconnection API promised by Turbo: the fixture reaches directly into Stimulus’ context and is pinned to that version. On each turbo:morph-element, it scans the whole application.controllers collection, but reconnects only controllers whose element is exactly target. On a large page, the cost comes from the repeated scan, not from reconnecting everything.

What breaks, and why

Remember

Any library that injects DOM absent from the server HTML, or writes class or style at runtime, can conflict with morphing. Whether it breaks depends on what the server returns and whether the library can reconcile or rebuild its state. It is not automatically incompatible, but that surface needs explicit protection or lifecycle handling.

Library What breaks The cause The fix
Chartkick The chart disappears, a “Loading…” stays behind Chartkick listens for turbo:before-render and destroys every chart, including when renderMethod is morph. Raw Chart.js does not do this on its own. The <script> that would recreate the chart 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 Server-render a stable wrapper containing both the control and the injected DOM, then put an id and data-turbo-permanent on that parent. Otherwise tear down before its morph and reinitialize afterwards
Leaflet, Mapbox GL Dead map, gray tiles, or Map container is already initialized The library’s injected internal DOM is removed while the container survives, so disconnect() does not run Destroy before the container morphs, reinitialize on turbo:morph-element, then call invalidateSize() with Leaflet or resize() with Mapbox GL
<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, or the UI thinks the cable is offline Reparenting through the insertBefore fallback unsubscribes it; an in-place morph can remove the runtime connected attribute without reconnecting it Stable id, outside reordered areas, plus protection for connected or data-turbo-permanent

Trix and Action Text have been fixed since March 2025, contrary to what many blog posts still claim. The technique used provides a useful 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

With Turbo, the same document and JavaScript context can remain active for a long time. Every resource owned by a controller instance needs its teardown in disconnect(): setInterval timers, addEventListener calls on window or document, IntersectionObserver and ResizeObserver instances (which keep the observed node alive and block collection of the entire detached subtree), Action Cable subscriptions, and Chart.js instances. A genuinely shared or singleton resource follows a different lifecycle, often with reference counting; every disconnect() must not destroy it.

WebGL contexts deserve a mention. Their quota depends on the browser, GPU and implementation. Keep leaking Mapbox maps and the browser may lose one or more contexts or refuse to create a new one; affected canvases can turn blank or remain unusable until they are reinitialized.

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 leaves the page. The network failure is recorded as SystemStatusCode.networkFailure, the adapter dispatches turbo:reload with reason: "request_failed", then performs a plain navigation. Offline, that means the browser’s network error page, with the application’s entire JavaScript context gone. You lose client state just as the network has already failed.

Under the hood internal

The name reload is misleading, and it took me a while to understand what was actually happening:

reload(reason) {
  dispatch("turbo:reload", { detail: reason })
  window.location.href = (this.redirectedToLocation || this.location)?.toString() || window.location.href
}

(browser_adapter.js#L130-L134) This is not a location.reload(). It is a full-page navigation to the visit’s destination, the one that just failed. So on an offline link click you do not land on the page you were on: you land on the network error page at the link’s address, and the previous page’s state is gone.

Two details that come with it. turbo:reload is dispatched without cancelable: listening for it lets you prevent nothing. And its detail is the whole reason object, so event.detail.reason is "request_failed" and event.detail.context.statusCode carries the internal code.

Hence the only interception point that works: turbo:fetch-request-error, earlier in the chain. The preventDefault() there is not cosmetic, since it makes #willDelegateErrorHandling return false, which short-circuits requestErrored, therefore recordResponse, so the adapter is never asked to do anything and the navigation does not happen. In exchange, FetchRequest#perform still rethrows the error: expect an unhandled promise rejection in the console.

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 }) => {
  const accept = request.headers.get('Accept') || ''

  return request.method === 'GET' && (
    request.mode === 'navigate' ||                                  // browser navigation
    (request.destination === '' &&                                  // Turbo Drive's fetch()
     !request.headers.has('Turbo-Frame') &&                          // not a frame request
     !accept.includes('text/vnd.turbo-stream.html') &&               // not a stream request
     accept.includes('text/html'))
  )
}

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

Both exclusions are essential: Frames and Streams also use fetch(), carry destination === "" and accept HTML. Without them, a partial response can pollute the cache for the same URL that Drive later visits. The second route works as is: those requests really are initiated by the browser and carry a populated destination.

This matcher also assumes that your other application fetch() calls do not request HTML over GET without a distinguishing header. If they do, the HTTP metadata above cannot identify Drive on its own: mark Drive requests with an application header from turbo:before-fetch-request and make the predicate require it.

Two service worker failures come up often with Turbo: a wrong Content-Type and inconsistent asset fingerprints. They are not the only possible failures.

Serving a response with the wrong Content-Type. On a Drive visit, Turbo records contentTypeMismatch, dispatches turbo:reload, then performs a full navigation to the destination. In a frame, a non-HTML response leaves the frame unchanged after the fetch events. Only that second case is silent in the interface.

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. A loop only appears if subsequent responses keep alternating between incompatible HTML and assets; one stale cache entry does not guarantee a loop on its own.

Rails 8 adds constraints of its own:

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

This production risk receives little attention in Hotwire documentation.

Action Cable is publish/subscribe with no persistence, no acknowledgment, 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 stays stale until a navigation, an explicit catch-up refresh, or a later payload resynchronizes it. There is no automatic replay or error indication.

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 public DOM observation surface fits in one attribute: <turbo-cable-stream-source> sets and removes connected. You can instrument subscription callbacks or the Action Cable consumer, but that couples the application to their internals.

Under the hood Verified

The element does dispatch an event, just not the one you would want: a MessageEvent("message") per received payload, which is how StreamObserver consumes it (cable_stream_source_element.js#L30-L33). What it does not dispatch is any lifecycle event at all: subscriptionConnected and subscriptionDisconnected only set and remove the attribute.

To stay on the custom element’s public interface, observe that attribute with a MutationObserver or, for a simple banner, CSS: turbo-cable-stream-source:not([connected]) ~ .offline-banner { display: block }.

There is one morphing trap to remove before observing it. connected is runtime state and is absent from the server HTML, so idiomorph will otherwise remove the attribute while the subscription is still alive. The custom element does not observe connected, so it will not put it back until a real reconnect. Preserve that one attribute during morphs:

document.addEventListener("turbo:before-morph-attribute", (event) => {
  if (event.detail.attributeName === "connected" &&
      event.target.matches("turbo-cable-stream-source")) {
    event.preventDefault()
  }
})

Hence the workaround, which is entirely 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. Focus survives when its node stays in place; idiomorph can also restore it on an identified <input> or <textarea>. This is what makes pairing morphing with broadcasts practical: 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 staleness.

Debugging checklist

This checklist has resolved nearly every Turbo bug I have encountered. It is deliberately mechanical: each step eliminates a class of causes, and the first three take ten seconds.

  1. The console, before the network tab. A console.error changes the diagnosis entirely. Form responses must redirect → HTTP status. unknown action or target or targets attribute is missing → the <turbo-stream> is malformed. Content missing → frame identifier. An empty console and nothing on screen: look first for a silent no-op, a missing target, or a canceled event. It narrows the diagnosis, but does not prove that the response was empty.

  2. Did the request leave, and with which headers? In the network tab, inspect Accept, Turbo-Frame and X-Turbo-Request-Id. Without text/vnd.turbo-stream.html in Accept, the usual content negotiation will not select that format, but a .turbo_stream extension or an explicit params[:format] still can. Inspect the URL and params too. If the request never left at all, look for a data-turbo="false" on an ancestor.

  3. The status, and only at this point. A 200 with no redirect on a POST is the classic. A turbo-stream Content-Type selects the stream renderer before page/frame rendering, but the status still feeds turbo:submit-end.detail.success. For frame navigation started by a link or src, both 2xx and 4xx/5xx HTML go through loadResponse(). For an ordinary HTML frame-form response, status instead selects the success/failure path, the frame that receives the response, and whether the Drive cache is cleared.

  4. Does the target actually exist, right now? In the console, document.getElementById("your_id"). Also check it is not inside a <template>, inside an <iframe>, or inside a loading="lazy" frame that has not loaded: three places Turbo will not look.

  5. How many of them are there? document.querySelectorAll("#your_id").length. The answer must be 1; a 2 explains both streams hitting the wrong element and morphs behaving strangely at a distance.

  6. Which renderer ran? document.addEventListener("turbo:before-render", e => console.log(e.detail.renderMethod)). MorphingPageRenderer needs an effective morph method and a page refresh in PageView’s sense. When there is a Visit, that means action: "replace" and the same pathname as the last rendered page; with no Visit, isPageRefresh() returns true directly. A successful full-page submission creates a Visit, while a failed response takes the direct rendering path. A missing <%= yield :head %> drops the morph meta tag, but it is only one possible cause.

  7. Is it coming from a broadcast? Check whether the HTML received is meant to be identical for everyone. With standard Devise, a broadcast partial that calls current_user fails with Devise::MissingWarden; with a home-grown helper it may return nil, but the partial still cannot safely personalize one shared payload.

  8. Run the same test in Safari, or on an iPhone. A difference limited to those browsers is a reason to check Element.prototype.moveBefore and idiomorph’s insertBefore fallback. It is not proof: application code, CSS and other API differences remain suspects. Reduce the case before clearing the application.

  9. Cut the network, and watch what happens. It is the only way to discover that an offline link click leaves your application, and that nobody is listening to turbo:fetch-request-error.

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 updates an unexpected region It inherits the frame that contains it, that frame’s target, or a data-turbo-frame on the form or submit button
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 A frame stays unchanged after the fetch events; a Drive visit dispatches turbo:reload and then navigates fully to the destination
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. turbo:before-cache is also path-dependent: absent after a successful unsafe form, and absent on Session#refresh unless a previewable snapshot of the current URL is already cached and the current document is cacheable
Morphing “does not work”, the page gets replaced On a page refresh, the effective method is not morph. Check the meta tag emitted by turbo_refreshes_with, hence <%= yield :head %>, or the method attribute on the refresh stream
Morphing does not trigger after a form For a successful full-page response turned into a Visit, the effective method must be morph, the action replace, and the pathname unchanged. Without an explicit action, Turbo chooses replace only for a redirect to the full departure URL; otherwise it chooses advance. A full-page 4xx goes straight through renderPage, a 5xx through ErrorRenderer, and a frame submission through its frame renderer: those branches do not apply this Visit test
Full browser reload on every navigation data-turbo-track="reload" signature mismatch. Normal after a deploy. A loop requires successive responses to remain inconsistent; look for a dynamically injected asset or a service worker cache mixing versions
Lists animate all over the place under morph No stable id on the items, or an id duplicated in either root of this morph, or the tag changed
Works on your machine, breaks on an iPhone, under morph Check for Element.prototype.moveBefore. The insertBefore fallback can disconnect and reconnect moved nodes; WebKit browsers often share that path, but this symptom alone proves neither the cause nor the innocence of application 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 With a usable restoration snapshot, that snapshot is the only render and Turbo performs no fetch. Without one, it goes back to the network. Use 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. Remove it in turbo:before-cache, the deterministic hook intended for that job. A synchronous disconnect() may also precede the deferred clone, but should not be your only cache contract
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, or a channel with no authorization check. Signing is not authorizing
A broadcast refreshes its author’s tab too Missing or nil request_id, a broadcast outside the request, or an identifier evicted after twenty new Turbo requests
Real-time updates stop, with no error The WebSocket dropped. Action Cable replays nothing. Catch up on the connected attribute after protecting it from morphs
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 navigates to the destination, outside Turbo. preventDefault() on turbo:fetch-request-error is the only way to stop it: turbo:reload is not cancelable
After a form returns 404, the frame shows the error but reload() returns to the old content A failed form response does not replace an existing src. Navigation started by a link or by src sets that URL before the request, so reload() then repeats the failing URL instead
After a 500 on a link click, the application goes unstable ErrorRenderer replaces the whole <head>, re-activates scripts unless they have data-turbo-eval="false", and does not run Bardo: data-turbo-permanent is not honoured
Back navigation got slow since a search form was added A failed GET with an HTML response clears the full-page cache; inside a frame, every failed form submission clears it
Some changes are not broadcast from a rails runner The process exited before the debounce task scheduled 0.5 s out. If it stays alive beyond the delay, the broadcast is sent

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, all of which belong under the Verified label: 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, reassigning event.detail.url on turbo:before-fetch-request, and the fact that turbo:frame-render is declared cancelable even though canceling it produces no effect.

The internal details here are linked to source or tests from the pinned versions; revisit those links whenever you upgrade Turbo.

The useful rule is still the one at the top: choose the replacement scope first, then decide who names its target. Use Drive while the whole page is the right boundary, a Frame when the client names one region backed by a URL, and Streams when the server must name one or more targets. Morphing only changes how that choice is applied.

If your Hotwire application has accumulated these symptoms, this is the work I do at SXN Labs. I start from a reproducible case, fix the broken contract and verify the result in production.