---
title: Turbo doesn't have three tools, it has three scopes
description: 'Drive, Frames, Streams and morphing explained through what connects
  them: who designates the target of the replacement. With the traps the official
  documentation does not mention, and a symptom index.'
url: https://sxnlabs.com/en/ruby/2026/08/06/turbo-drive-frames-streams-reference/
lang: en
date: '2026-08-06T09:00:00+02:00'
tags:
- Hotwire
- Turbo
- Rails
- Morphing
- Stimulus
---

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

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

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

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

<nav class="sommaire" aria-label="Article contents">
  <p class="sommaire-titre">What's inside</p>
  <ol>
    <li><a href="#the-model-in-one-page">The model in one page</a><span>the three scopes, and who designates the target</span></li>
    <li><a href="#turbo-drive">Turbo Drive</a><span>what is intercepted, the lifecycle, the cache, the config</span></li>
    <li><a href="#turbo-frames">Turbo Frames</a><span>the identifier rule, the layout, the attributes that matter</span></li>
    <li><a href="#turbo-streams">Turbo Streams</a><span>the two worlds, the controller-to-view path, the eight actions</span></li>
    <li><a href="#the-http-status-and-why-422">The HTTP status, and why 422</a></li>
    <li><a href="#morphing">Morphing</a><span>what it removes, idiomorph, the pantry, field values</span></li>
    <li><a href="#broadcasts">Broadcasts</a><span>the macros, the request-id, the debounce, stream security</span></li>
    <li><a href="#stimulus-and-third-party-libraries">Stimulus and third-party libraries</a></li>
    <li><a href="#network-and-offline">Network and offline</a><span>fetch failures, service workers, Action Cable</span></li>
    <li><a href="#the-decision-tree">The decision tree</a><span>which tool for which need</span></li>
    <li><a href="#the-symptom-index">The symptom index</a><span>you have a bug, you start here</span></li>
    <li><a href="#what-changed-recently">What changed recently</a></li>
  </ol>
  <p class="sommaire-note">If you are here because something is broken, go straight to item 11 and work your way back up to the relevant section.</p>
</nav>

## The model in one page

<figure class="schema">
  <img width="880" height="494" src="/images/posts/turbo/en/01-trois-portees.svg" alt="Three panels comparing Turbo Drive, which replaces the whole page, Turbo Frames, which replaces a fragment designated by the client, and Turbo Streams, which applies commands to targets designated by the server." />
  <figcaption>The same mechanism, three scopes. What changes from one column to the next: the extent of the replacement, and who designates its target.</figcaption>
</figure>

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

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

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

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

## Turbo Drive

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

### What is intercepted, and what is not

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

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

### The lifecycle

<figure class="schema">
  <img width="1000" height="742" src="/images/posts/turbo/en/02-cycle-drive.svg" alt="Vertical sequence of the events of a Drive visit, from turbo:click to turbo:load, with the preview from the cache and the body replacement step." />
  <figcaption>The dashed boxes are not events: they are the internal steps the events bracket.</figcaption>
</figure>

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

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

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

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

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

### The cache, in numbers

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

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

Three levers:

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

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

### Turbo.config

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

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

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

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

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

### Prefetching

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

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

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

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

## Turbo Frames

### It all comes down to one identifier

<figure class="schema">
  <img width="880" height="584" src="/images/posts/turbo/en/03-contrat-frame.svg" alt="A frame sends the Turbo-Frame header, the server responds with a full page, Turbo extracts only the turbo-frame with the same id, and the rest is discarded. Below, the frame-missing sequence." />
  <figcaption>Turbo does exactly one thing with the response: it looks for a <code>&lt;turbo-frame&gt;</code> with the same id. Everything else is discarded.</figcaption>
</figure>

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

```erb
<%%= turbo_frame_tag turbo_frame_request_id || "invoice_detail" do %>
  …
<%% end %>
```

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

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

And if nothing matches, the sequence is this:

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

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

```erb
<%%= 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`:

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

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

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

```ruby
layout :layout_for_request

private

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

No error, no warning. Just a silent bill.

### The attributes that matter

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

Two behaviors that often surprise people:

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

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

## Turbo Streams

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

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

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

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

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

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

### The full path, from controller to view

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

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

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

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

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

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

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

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

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

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

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

```erb
<%%# 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>
```

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

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

### Eight actions, and `morph` is not one of them

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

| Action | What it does | Target required |
|---|---|---|
| `append` | Adds to the end of the target's content | <span class="mark-yes" role="img" aria-label="yes">✓</span> |
| `prepend` | Adds at the beginning | <span class="mark-yes" role="img" aria-label="yes">✓</span> |
| `before` | Inserts before the target | <span class="mark-yes" role="img" aria-label="yes">✓</span> |
| `after` | Inserts after the target | <span class="mark-yes" role="img" aria-label="yes">✓</span> |
| `replace` | Replaces the target itself | <span class="mark-yes" role="img" aria-label="yes">✓</span> |
| `update` | Replaces the target's content | <span class="mark-yes" role="img" aria-label="yes">✓</span> |
| `remove` | Removes the target. No `<template>` | <span class="mark-yes" role="img" aria-label="yes">✓</span> |
| `refresh` | Triggers a page refresh. No `<template>` | <span class="mark-no" role="img" aria-label="no">✕</span> |

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

Two barely documented behaviors:

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

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

### `target` and `targets` are not the same thing

```ruby
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:

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

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

### The silence when the target does not exist

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

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

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

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

### Adding your own actions

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

On the Ruby side, a load hook:

```ruby
# 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:

```js
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:

```ruby
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

<figure class="schema">
  <img width="940" height="664" src="/images/posts/turbo/en/04-reponse.svg" alt="Decision tree: turbo-stream content-type, then frame request, then HTTP status, with the four outcomes 200, 4xx, 5xx and 3xx." />
  <figcaption>Three questions, in this order. The HTTP status is consulted last, and only for a full-page navigation.</figcaption>
</figure>

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

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

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

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

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

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

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

- **303** always forces the switch to `GET`.
- **301** and **302** force the switch to GET **only for POST**. A `DELETE` redirected with a 302 is **replayed as a `DELETE`** on the target URL.
- **307** and **308** always preserve the method.

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

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

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

## Morphing

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

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

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

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

### What actually triggers a morph

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

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

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

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

### What morphing removes

<figure class="schema">
  <img width="880" height="676" src="/images/posts/turbo/en/05-morph.svg" alt="Comparison of the two render pipelines: classic, with before-cache, body replacement and script re-execution, against morph, where before-cache no longer happens and nothing is re-executed." />
  <figcaption>Same beginning, same end. It is the middle that changes, and the middle is where your code lives.</figcaption>
</figure>

Three corrections to some very widespread folklore:

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

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

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

### idiomorph decides by identifiers

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

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

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

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

### The pantry, and why it breaks in Safari

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

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

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

- With `moveBefore` (Chromium 133 and up, Firefox 144 and up), the node **stays connected**. No Stimulus `disconnect`/`connect`, `<iframe>` elements do not reload, a `<video>` keeps playing.
- With `insertBefore`, the node is **disconnected then reconnected**. `disconnect()` then `connect()` are called, `<iframe>` elements reload, video playback restarts from zero, CSS transitions restart, and the `connectedCallback` of custom elements replays.

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

### Field values are not protected

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

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

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

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

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

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

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

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

### The protection toolbox

From the finest to the most brutal:

```js
// 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 }) => { /* … */ })
```

```erb
<%%# 4. The complete freeze %>
<div id="carte" data-turbo-permanent>…</div>
```

Two clarifications the documentation does not give.

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

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

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

## Broadcasts

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

### Four macros, and one asymmetry

```ruby
class Card < ApplicationRecord
  broadcasts_refreshes_to :board   # a single after_commit, everything goes to board
end
```

| Macro | Create | Update | Destroy |
|---|---|---|---|
| `broadcasts_to :board` | `append` to `board` | `replace` to `board` | `remove` to `board` |
| `broadcasts` | `append` to `"cards"` | `replace` to **the record's GID stream** | `remove` to **the GID stream** |
| `broadcasts_refreshes` | `refresh` to `"cards"` | `refresh` to **the GID stream** | `refresh` to **the GID stream** |
| `broadcasts_refreshes_to :board` | `refresh` to `board` | `refresh` to `board` | `refresh` to `board` |

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

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

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

### The full path of a refresh

<figure class="schema">
  <img width="880" height="588" src="/images/posts/turbo/en/06-broadcast.svg" alt="Sequence diagram between the writing tab, the server and a watching tab, showing the X-Turbo-Request-Id header, the 0.5 second debounce, and the refresh being dropped in the originating tab." />
  <figcaption>The tab that writes receives its own broadcast. The whole identifier mechanism exists to let it ignore that broadcast.</figcaption>
</figure>

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

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

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

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

### The debounce, and what it costs

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

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

Two less pleasant consequences:

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

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

### `current_user` does not exist in a broadcast

Every asynchronous broadcast renders its partial through:

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

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

- **`current_user` is `nil`.** Devise returns `nil` rather than raising: a partial that hides an admin button behind `if current_user.admin?` therefore renders the non-admin branch **for everyone**, silently, with nothing in the console or in the logs.
- **`Current.*` attributes are empty**, for the same reason.
- **`ApplicationController` is hard-coded.** No `prepend_view_path`, no ivars set by a `before_action`, no helpers from another controller.
- **`_url` helpers raise `Missing host to link to!`** on Rails 7.1 and later, because the renderer's defaults no longer include an `http_host`. The `_path` helpers work.

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.

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

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

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

### A signed stream is not an authorized stream

```erb
<%%= turbo_stream_from "quotes" %>
```

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

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

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

```ruby
broadcasts_refreshes_to ->(quote) { [quote.company, :quotes] }
```

```erb
<%%= turbo_stream_from current_company, :quotes %>
```

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

### Two plumbing traps

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

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

## Stimulus and third-party libraries

### The lifecycle, precisely

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

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

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

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

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

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

### What breaks, and why

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

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

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

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

### What leaks if you do not clean up

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

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

### Injecting a stream without an HTTP response

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

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

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

## Network and offline

### When the request fails, nothing happens

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

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

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

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

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

```js
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:

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

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

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

Two ways to break Turbo from a service worker:

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

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

Three constraints specific to Rails 8:

- **Put the service worker at a stable root URL.** An asset digested by Propshaft cannot control a scope beyond its own directory, and its URL changes as soon as its content changes. Two options: `public/service-worker.js`, or the route Rails 8 generates, which is **commented out by default** in `config/routes.rb` and therefore has to be uncommented: `get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker`. Note the `rails/pwa` controller and the path without `.js`.
- **Importmaps do not apply inside a service worker.** There is no `<script type="importmap">` in there. `importScripts()` or a classic worker with no imports.
- **Digested asset URLs change with their content.** A precache list written by hand goes stale on the first deploy that touches a file. Prefer runtime caching, by `request.destination`.

An official patch is in progress ([turbo#1427](https://github.com/hotwired/turbo/pull/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

<figure class="schema">
  <img width="880" height="476" src="/images/posts/turbo/en/07-websocket.svg" alt="Timeline showing broadcasts lost during a WebSocket outage, with no error and no log, and the callout on the connected attribute as the only signal." />
  <figcaption>The only signal available is an attribute on an element. There is no event, no sequence number, no catch-up.</figcaption>
</figure>

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

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

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

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

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

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

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

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

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

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

## The decision tree

<figure class="schema">
  <img width="940" height="710" src="/images/posts/turbo/en/08-decision.svg" alt="Four cascading questions: does the URL have to change, a single zone with its own URL, several zones or a server-driven update, purely client state. Leading respectively to Drive, Frame, Streams, Stimulus." />
  <figcaption>The first yes gives you the tool. Going one step further down always costs more than stopping.</figcaption>
</figure>

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

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

## The symptom index

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

## What changed recently

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

- `data-turbo-cache="false"` was **removed**. It is `data-turbo-temporary`.
- `Turbo.clearCache()` was **removed**. It is `Turbo.cache.clear()`.
- `data-turbo-frame="_parent"` was **added**.
- The `method` and `scroll` attributes on the `refresh` stream action were added: they override the meta tags, broadcast by broadcast.
- `before` and `after` now deduplicate siblings, as `append` and `prepend` already did for children.

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

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