Ruby on Rails
Hotwire applications risk database degradation and cross-tenant data leaks when teams bind stream broadcasts directly to ActiveRecord callbacks instead of isolating operational triggers and delegating personalization to client-side Stimulus controllers.
AI-assisted summary of Practical Hotwire: Stimulus & Turbo on the Ground - Jeremy Smith — original source. Check the original for full context.
Source published:
Key ideas
- Turbo 8's default 100ms hover prefetching can dramatically inflate server compute and database traffic while triggering unintended side effects; teams should consider turning it off globally via data-turbo-prefetch="false" and opting in selectively. (10:26)
- Self-loading Turbo Frames can introduce severe N+1 HTTP request bottlenecks: benchmarked across 20 CI pipeline runs, self-loading frames required 21 requests, 81 queries, and 110ms compared to a single eager-loaded request taking 3 queries and 30ms. (15:48)
- Turbo Streams fail silently without warnings when a target ID is missing or ambiguous; developers should use Rails' dom_target helper instead of standard dom_id to assign role-specific IDs when displaying records across multiple UI components. (22:09)
- Broadcasting personalized HTML directly from background jobs fails because workers lack request context; real-time feeds should broadcast canonical UTC timestamps and record IDs, delegating personalized rendering (like relative time and 'You' labels) to client-side Stimulus controllers. (26:52)
Detailed notes
## Background This talk was delivered by Jeremy Smith and published on YouTube by the Ruby on Rails channel on October 1, 2026, following Rails World in Austin, Texas. According to the page description, it provides a hot-sauce-themed guide through the 12 most common pitfalls developers encounter with Hotwire, covering Stimulus, Turbo Drive, Turbo Frames, and Turbo Streams.
## TL;DR Jeremy Smith systematically deconstructs twelve common pitfalls across Hotwire's core libraries by comparing the painful lessons of developer judgment to climbing a Scoville heat scale. He shows how to replace monolithic Stimulus controllers and manual event listeners with composable, declarative behaviors, and explains how to prevent Turbo Drive issues like state leakage, frozen history snapshots, and aggressive link prefetching. For Turbo Frames, he tackles missing content recovery, the performance drag of self-loading N+1 HTTP requests, and the boundaries of inline editing. Finally, he outlines robust Turbo Stream strategies, including precise DOM targeting via dom_target, decoupling broadcast delivery from ActiveRecord callbacks, and using lightweight Stimulus controllers to personalize generic broadcast payloads on the client.
## Atmosphere & style The presentation carries the playful, high-energy vibe of a Hot Ones tasting flight, filled with culinary metaphors, Scoville references, and self-deprecating developer humor. Smith speaks with the confident, battle-tested pragmatism of a seasoned engineer who has personally felt every burn he describes, keeping the conference audience chuckling at relatable production war stories. As the examples escalate from mild listener mistakes to blazing WebSocket personalization bugs, the tone shifts from lighthearted advice to sharp architectural mastery, leaving viewers both cautioned and empowered.
## Bulletin - [10:26](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=626s) Turbo 8's default 100ms hover prefetching can dramatically inflate server compute and database traffic while triggering unintended side effects; teams should consider turning it off globally via data-turbo-prefetch="false" and opting in selectively. - [15:48](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=948s) Self-loading Turbo Frames can introduce severe N+1 HTTP request bottlenecks: benchmarked across 20 CI pipeline runs, self-loading frames required 21 requests, 81 queries, and 110ms compared to a single eager-loaded request taking 3 queries and 30ms. - [22:09](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1329s) Turbo Streams fail silently without warnings when a target ID is missing or ambiguous; developers should use Rails' dom_target helper instead of standard dom_id to assign role-specific IDs when displaying records across multiple UI components. - [26:52](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1612s) Broadcasting personalized HTML directly from background jobs fails because workers lack request context; real-time feeds should broadcast canonical UTC timestamps and record IDs, delegating personalized rendering (like relative time and 'You' labels) to client-side Stimulus controllers.
## Key takeaways - [03:08](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=188s) Stimulus applications should favor declarative HTML action descriptors over manually binding and unbinding event listeners inside connect and disconnect lifecycle hooks. - [04:09](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=249s) Screen-specific monolithic Stimulus controllers limit reuse; breaking complex forms into composable utility behaviors (such as autosave, autosize, and character-count) creates flexible, modular front-ends. - [05:38](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=338s) Duplicating parsing and validation logic in client-side JavaScript creates technical debt; leveraging HTML's formaction attribute allows the Rails server to perform processing and stream previews directly. - [08:01](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=481s) Turbo Drive persists JavaScript state across visits, making it essential to tear down third-party UI widgets inside Stimulus disconnect hooks to prevent duplicate elements on back-button navigation. - [09:00](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=540s) Turbo Drive cache snapshots can inadvertently freeze transient UI state into history; marking flash alerts with data-turbo-temporary and closing active modals on disconnect preserves clean page restorations. - [10:26](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=626s) Automated 100ms link prefetching in Turbo 8 can distort analytics and overwhelm backends with unclicked page requests, requiring global disablement and targeted opt-in link attributes. - [12:19](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=739s) The Turbo Frame 'Content missing' error frequently arises from contract drift or standard HTTP transitions like 404s and login redirects, which should be intercepted and handled gracefully via turbo:frame-missing events. - [15:48](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=948s) Replacing server-rendered collections with self-loading Turbo Frames traded aggregate server efficiency for client perception, introducing costly N+1 HTTP request waterfalls. - [18:13](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1093s) Turbo Frames are architecturally restricted to updating a single DOM region per response; when an interaction requires updating disconnected layout elements simultaneously, applications must graduate to Turbo Streams. - [22:09](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1329s) Turbo Streams omit both server and client error notifications for invalid targets, making the dom_target helper crucial for scoping unambiguous DOM element IDs across complex interfaces. - [23:49](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1429s) Coupling Turbo Stream broadcasts directly to ActiveRecord lifecycle callbacks risks cross-tenant data leaks and causes thundering broadcast storms during batch database imports. - [26:52](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1612s) Asynchronous background jobs lack access to request-level state such as Current.user, requiring broadcasts to contain depersonalized data that client-side Stimulus controllers contextualize using layout meta tags.
## Detailed walkthrough ### [00:11](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=11s) The Spice of Developer Judgment: "Take a Bite and Feel the Burn"
Broadcasting from the Rails World stage in Austin, Texas, Jeremy Smith opens with a nod to the show *Hot Ones*, framing his presentation as a session of "hot questions and even hotter sauces." To ground the audience, he poses a series of diagnostic questions aimed at anyone identifying as a seasoned Rails developer: When did you learn never to send an email inside an ActiveRecord callback? Why does spotting an external third-party API call directly inside a controller action make your stomach churn? Why do experienced developers instinctively reach for `Time.current` over `Time.now`, flinch when an instance variable creeps into a shared partial, or look at a `dependent::destroy` association and immediately wonder, *"Yeah, but how many in that `has_many`?"*
Smith argues that this instinct—developer judgment—is never innate. Instead, it is acquired through painful firsthand experience, a cycle he summarizes as: "Take a bite and feel the burn." - You take a bite when you write code; you feel the burn when it goes to production. - You take a bite when you architect an elaborate subsystem; you feel the burn a year later when you return to it and cannot understand what you built. - You take a bite when you copy an enticing pattern from a blog post; you feel the burn when you realize it only ever worked for that specific author's exact scenario.
While sampling a little spice builds tolerance and keeps development exciting, relying on unchecked heat for your daily engineering diet leads to perpetual heartburn. The goal of the talk is to systematically catalog how developers get burned not by Rails itself, but by its front-end companion: Hotwire.
---
[01:58](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=118s) Setting the Table: Hotwire's Four Flights
Before diving into specific pitfalls, Smith sets the baseline architecture of Hotwire. At its core, Hotwire is an alternative front-end toolkit designed to favor minimal JavaScript, server-side HTML rendering, and lightweight network payloads. It offers back-end engineers a direct path to rich, interactive user interfaces without the overhead, bundle sizes, or architectural churn of heavyweight single-page application (SPA) frameworks.
Hotwire breaks down into three primary layers: 1. **Stimulus**: A modest JavaScript framework that binds behavior directly to existing server-rendered HTML. 2. **Turbo**: The engine managing page changes and real-time DOM updates. 3. **Turbo Native**: Adapters for wrapping Hotwire-driven web flows inside native iOS and Android shells.
Turbo further subdivides into three distinct mechanisms: - **Turbo Drive**: Full-page navigation enhancements that prevent hard browser refreshes. - **Turbo Frames**: Scoped, independent page regions that update in isolation. - **Turbo Streams**: Surgical DOM mutations (append, prepend, replace, update, remove) delivered over HTTP responses or WebSockets.
Setting Turbo Native aside for the session, Smith organizes the tour across four flights: Stimulus, Turbo Drive, Turbo Frames, and Turbo Streams. Sampling three distinct burns per flight yields twelve common traps where Hotwire applications catch fire.
---
[03:08](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=188s) Stimulus Burn 1: Manual Event Listeners vs. Built-in Actions
Stimulus is the workhorse of Hotwire's client-side layer, accounting for roughly 90% of all JavaScript written in a typical application. It provides small JavaScript controller classes that target HTML elements via `targets`, react to interactions via `actions`, and track lightweight state via `values`. Smith introduces the first burn as a mild, entry-level trap that nonetheless appears constantly in client code and AI-generated agent scripts: manually registering DOM event listeners inside Stimulus controllers.
``` ┌─────────────────────────────────────────────────────────┐ │ ANTI-PATTERN: Manual Listener Wiring │ │ │ │ connect() ──► addEventListener("click",...) │ │ ──► addEventListener("keydown",...) │ │ disconnect() ──► removeEventListener(...) │ │ (Boilerplate, memory-leak prone, neglects Stimulus) │ └─────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ STIMULUS IDIOM: Declarative HTML Actions │ │ │ │ data-action="click->dropdown#toggle │ │ click@window->dropdown#hide │ │ keydown.esc@window->dropdown#hide" │ │ (Zero connect/disconnect ceremony, clean lifecycle) │ └─────────────────────────────────────────────────────────┘ ```
Consider a standard dropdown menu requirement: toggle the menu open/closed on button click, close the menu when clicking outside the menu element, and dismiss the menu on hitting the Escape key. Developers steeped in vanilla JavaScript or older frameworks often jump into the controller's `connect()` lifecycle method to manually attach `addEventListener` calls for mouse clicks and keyboard events, followed by tedious cleanups in `disconnect()`.
Smith reminds the room that "Hotwire is trying to help us write less JavaScript, and that usually means writing more HTML." Stimulus actions provide all of these listeners out of the box with zero lifecycle wiring. By declaring scoped action descriptors directly in markup—such as binding window-level clicks or escape key presses (`click@window->dropdown#hide`, `keydown.esc@window->dropdown#hide`)—Stimulus handles registration, global scoping, filtering, and teardown automatically.
---
[04:09](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=249s) Stimulus Burn 2: The Monolithic Controller Trap
Moving up the Scoville scale, Smith highlights an anti-pattern that "lacks flavor and tends to drown the plate": building monolithic, single-purpose Stimulus megacontrollers named after specific UI screens instead of general behaviors.
He presents a user profile bio input requiring three specific features: - A live 280-character countdown. - An auto-expanding textarea that grows dynamically with input length. - An autosaving mechanism that triggers whenever the user pauses typing.
The common impulse is to create a single `ProfileBioController` attached directly to the form, cramming character counting, textarea height calculations, and debounced save timers into one monolithic class. While this satisfies the ticket, it permanently couples three generic behaviors to a single component. When the application later introduces another textarea needing auto-expansion without character limits, or a separate settings form requiring autosave without a textarea, none of that logic can be reused.
``` ┌──────────────────────────────────────────────────────────────┐ │ Monolithic Approach: tightly coupled, zero reuse │ │ │ │ <form data-controller="profile-bio"> │ │ (handles counter logic, textarea resizing, and save timer) │ │ </form> │ └──────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────┐ │ Composable Approach: small, single-purpose controllers │ │ │ │ <form data-controller="autosave"> │ │ <div data-controller="character-count"> │ │ <textarea data-controller="autosize"></textarea> │ │ <span data-character-count-target="counter"></span> │ │ </div> │ │ </form> │ └──────────────────────────────────────────────────────────────┘ ```
Stimulus shines when developers treat controllers like composable utility behaviors. Breaking the monolithic controller into three distinct, reusable units makes the entire codebase flexible: - An `autosave` controller bound to the `<form>`. - An `autosize` controller mounted on the `<textarea>`. - A `character-count` controller wrapping both the text input and its counter indicator markup.
---
[05:38](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=338s) Stimulus Burn 3: Reinventing Server Logic on the Client
The third Stimulus burn centers on a philosophical contradiction: developers choose Hotwire because they love server-side rendering, yet they frequently write intricate client-side JavaScript to solve problems that the Rails server can already handle effortlessly. On a small scale, this manifests as manually concatenating URL strings in JavaScript rather than passing fully resolved paths from Rails route helpers through Stimulus value attributes.
On a larger scale, this leads to entire duplicate feature implementations. Smith illustrates this with a batch email subscriber import interface. Users paste a large list of raw email addresses into a textarea. Before committing them to the database, they want to preview the imported list. The UI provides two buttons: "Check" and "Add", alongside summary stats on address validity and duplicate detection.
```mermaid flowchart TD accTitle: Server-Side Preview Pattern accDescr: Flow showing how a preview button submits to an alternate endpoint via formaction and renders via Turbo Streams. subgraph ClientUI["Client Form Markup"] Form["<form action='/subscribers' method='post'>"] Textarea["<textarea name='emails'>"] CheckBtn["<button formaction='/subscribers/preview'>Check</button>"] SubmitBtn["<button type='submit'>Add</button>"] PreviewTarget["<div id='import_preview'></div>"] end
subgraph RailsServer["Rails Backend"] PreviewAction["SubscribersController#preview"] ParseLogic["Deduplicate & Validate Emails"] TurboStreamResponse["Render turbo_stream.update 'import_preview'"] end
CheckBtn -->|"Submits form payload"| PreviewAction PreviewAction --> ParseLogic ParseLogic --> TurboStreamResponse TurboStreamResponse -->|"Surgical HTML replacement"| PreviewTarget ```
Assuming that no records are being created immediately, developers frequently build an expansive Stimulus controller to parse raw strings, run regular expressions, prune duplicate emails, and render summary feedback on the client. But when the user finally hits "Add", the server must re-execute that identical parsing, validation, and deduplication pipeline in Ruby to guarantee data integrity. Maintaining dual implementations—one in JavaScript and one in Ruby—creates immediate maintenance debt.
Instead, developers can leverage standard HTML attributes: - Keep the single `<form>` pointing to the standard creation endpoint. - Attach an alternate `formaction` attribute to the "Check" button pointing to a `/subscribers/preview` endpoint. - Include an empty `<div>` on the page to receive results. - Let Rails process the text area on the back end and return a Turbo Stream replacing the preview container with the parsed validation metrics.
Whenever client complexity begins ballooning, Smith recommends making a habit of asking: *"Can and should the server be doing this work?"*
---
[06:55](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=415s) Flight 2: Turbo Drive — The Evolution of Turbolinks
Switching to Flight 2, Smith turns to Turbo Drive. Drive acts as a largely transparent, "set it and forget it" upgrade to traditional web browsing. Inheriting the core architecture of Turbolinks, Drive automatically intercepts link clicks and form submissions throughout the application.
Rather than letting the browser execute a full, blank-screen page reload, Drive performs an asynchronous `fetch` request behind the scenes, inspects the returning HTML document, swaps out the `<body>` element, and merges changes into the document `<head>`. While Turbo Drive requires minimal explicit configuration, its underlying execution model breaks several standard assumptions about browser lifecycles, leading directly to confusing UI bugs if left unmanaged.
---
[08:01](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=481s) Turbo Drive Burn 4: Leaking State and the Disconnect Lifecycle
The first Drive burn illustrates what happens when developers overlook the Stimulus element lifecycle during Drive page transitions. Because Turbo Drive replaces the page body without a full browser reload, JavaScript runtimes and global browser state persist across visits. If a controller fails to clean up after itself when an element leaves the DOM, orphaned behavior and duplicated DOM nodes begin piling up.
Smith demonstrates this failure using a date picker input powered by the third-party Flatpickr library: - A developer creates a Stimulus controller, imports Flatpickr, and initializes the date picker widget inside the `connect()` lifecycle hook. - When visiting the page initially, the picker works as expected. - However, when navigating to another link and clicking the browser's back button, two stacked date pickers suddenly appear over the input: a broken, inert widget left behind from the prior visit, and a new, active picker attached on top. - Navigating away and returning repeatedly appends another broken widget on every visit.
```javascript // The Disconnect Cleanup Fix import { Controller } from "@hotwired/stimulus" import flatpickr from "flatpickr"
export default class extends Controller { connect() { this.picker = flatpickr(this.element) }
disconnect() { // Teardown is essential when Turbo Drive removes the element this.picker.destroy() } } ```
The issue stems from failing to clean up external library instances on element removal. Implementing the `disconnect()` lifecycle method to destroy the Flatpickr instance ensures that whether an element is removed by page navigation, Turbo Frame replacement, or DOM removal, third-party libraries cleanly tear down their associated UI elements and listeners.
---
[09:00](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=540s) Turbo Drive Burn 5: Stale Cache Snapshots on Navigation
The next burn strikes developers directly when using the browser's back and forward history buttons. When a user navigates away from a page, Turbo Drive takes an immediate DOM snapshot and writes it to an in-memory cache. Drive uses these cached snapshots in two places: 1. To instantly display a preview of the destination page during standard forward navigations while waiting for the network fetch to complete. 2. To provide instantaneous page restoration during back and forward history traversal.
If a page contains ephemeral UI state at the moment the snapshot is taken, that temporary state gets permanently frozen into the history cache. For example, if a user updates their account on a settings page, Rails displays a green flash notice ("Settings successfully saved"). If the page also renders an open confirmation modal for a destructive action, leaving the page caches both the flash notice and the active modal. Returning to the settings page via the back button causes the flash alert and modal to flash on screen again unexpectedly.
To ensure cache snapshots remain pristine, Smith outlines several defensive techniques:
| Technique | Application | Behavior | |:--- |:--- |:--- | | `data-turbo-temporary` | Flash alerts, transient toast banners | Turbo Drive automatically strips matching elements from the DOM tree before serializing the cache snapshot. | | Stimulus `disconnect()` | Interactive modals, expanded confirmation drawers | Reverts temporary UI changes (e.g., closing drawers, removing active modal backdrops) when navigating away. | | `turbo:before-cache` event | Custom DOM normalization | Global event listener allowing developers to perform custom resets or state cleanups right before serialization. | | Caching Opt-Out (`<meta name="turbo-cache-control" content="no-cache">`) | Highly dynamic, sensitive screens | Instructs Turbo Drive never to store the page in the snapshot cache. |
Applying `data-turbo-temporary` to flash banners and tearing down open modal states on disconnect ensures that returning to the settings page restores clean, uncorrupted markup.
---
[10:26](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=626s) Turbo Drive Burn 6: The 100ms Prefetching Surprise
Introducing the final Drive burn, Smith points to a standard feature introduced in Turbo 8 that can quickly catch infrastructure teams off guard: automated link prefetching. While designed to make applications feel snappy, its default configuration can easily violate the principle of least surprise.
Under Turbo 8 defaults, whenever a user hovers their cursor over any eligible link for just 100 milliseconds, Turbo Drive immediately fires a background fetch request to prefetch that link's destination page. While prefetching provides a dramatic perceived performance boost for users who ultimately click the link, it also triggers server-side rendering for every casually hovered link, drastically increasing background server load and database traffic.
[10:52](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=652s) Turbo Drive Prefetching and Server-Side Side Effects
Hover-triggered prefetching can easily backfire because the vast majority of mouse hovers never turn into actual clicks. For applications that have not already heavily optimized their server-side response times, prefetching pages that are rendered and immediately thrown away introduces unnecessary compute overhead. Even worse, prefetching can trigger unintended side effects if standard `GET` requests alter application state—such as incrementing server-side page view counters or analytics.
Jeremy illustrates this with an example of a high-traffic e-commerce product catalog. Individual product detail pages might be relatively slow to render, and they increment a database-backed page view counter to track product popularity. Under Turbo's default configuration, a customer simply browsing and waving their mouse across a grid of product cards triggers a storm of expensive background requests and artificially inflates view counts—all for the theoretical speedup of a single click.
To prevent this server strain and data corruption, Jeremy recommends turning prefetch off by default across the entire application and re-enabling it selectively where the latency reduction actually justifies the compute waste:
- **Disable prefetching globally**: Add `data-turbo-prefetch="false"` to the application's `<body>` tag. - **Opt in selectively**: Add `data-turbo-prefetch="true"` only to high-value links, such as the top row of featured or best-selling catalog items. - **Inspect request headers for side effects**: For endpoints with unavoidable side effects, inspect incoming headers for `Sec-Purpose` (or `X-Sec-Purpose`). If the header indicates a prefetch request, bypass counters or state modifications conditionally.
---
[12:19](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=739s) Turbo Frames: The Notorious "Content Missing" Error
Flight three begins the tour of Turbo Frames, Hotwire’s primitive for decomposing a page into independent regions where link clicks and form submissions are scoped to update only that frame. However, developers working with frames quickly encounter the dreaded "Content missing" error. When a click or submission occurs inside (or targets) a frame, Turbo expects the server's HTML response to contain a `<turbo-frame>` tag with an identical `id`. If that matching ID is absent, Turbo replaces the container with a generic "Content missing" message and raises a JavaScript `turbo:frame-missing` error in the console.
```mermaid flowchart TD accTitle: Turbo Frame Missing Recovery Flow accDescr: Flowchart showing how a Turbo Frame request handles missing frame IDs using a Stimulus recovery controller.
subgraph Request ["Client Interaction"] Click["User clicks link targeting frame ID"] --> Req["Fetch request to server"] end
subgraph Responses ["Server Response Scenarios"] Req --> Match["Matching <turbo-frame id='...'>"] Req --> Diverge["No Matching Frame ID"] Diverge --> Bug["Branch 1: Bug / Contract Drift<br>(Missing wrapper in partial)"] Diverge --> Fallback["Branch 2: Valid HTTP Transition<br>(404 Not Found or 302 Login Redirect)"] end
subgraph Handling ["Stimulus Frame Recovery"] Match --> Swap["Turbo swaps frame content"] Bug --> Missing["turbo:frame-missing event fires"] Fallback --> Missing Missing --> Controller["frame-recovery Stimulus Controller"] Controller --> Check{"Response Type?"} Check -->|"302 / Auth Redirect"| Visit["Execute Turbo.visit to Login"] Check -->|"404 / App Error"| Report["1. Report to Error Tracker<br>2. Reveal fallback UI message"] end ```
Jeremy explains this with a moderator review queue containing a list of submissions on the left and a Turbo Frame preview panel on the right. While loading clicked submissions into the panel works most of the time, users occasionally see "Content missing" due to two distinct failure modes:
1. **Contract drift over time**: A developer creates a new conditional branch, view state, or refactors shared partials without realizing that a wrapping `<turbo-frame>` tag is required. When requested, the server returns HTML without the frame ID. While easy to catch on isolated views, this frequently slips into production when partials are reused across multiple controllers. 2. **Standard alternate HTTP responses**: The targeted record might have been deleted or archived by another user, causing the endpoint to render a standard 404 error page. Alternatively, the user's session may have timed out, triggering a redirect to the login screen. Neither standard 404 pages nor authentication views contain the specific panel's frame ID.
Rather than littering every shared error layout and login page with arbitrary frame IDs, Jeremy recommends wrapping the frame in a container handled by a custom Stimulus controller (e.g., `frame-recovery`). When the controller intercepts the `turbo:frame-missing` event, it inspects the response: if it was a redirect (such as session expiration), it directs Turbo to visit that destination; if it was an error, it reveals a friendly, hidden fallback message to the user while reporting the stack trace and missing frame ID to an exception tracker like Sentry or Bugsnag. This differentiates between expected edge-case UX and actionable developer bugs. Reflecting on this hot-sauce tier, Jeremy jokes: "People tend to love this label and they don't even notice that they're sweating."
---
[15:48](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=948s) Self-Loading Frames and the N+1 HTTP Request Trap
One of the marquee features of Turbo Frames is self-loading: rendering an empty `<turbo-frame id="..." src="...">` tag containing a loading spinner, leaving the browser to fetch and render the contents asynchronously on page load. While often treated as an easy performance win, Jeremy warns that "what feels like a quick perf win can come back to bite you if you forget that each frame load is a separate request."
Jeremy illustrates the danger using a continuous integration (CI) dashboard that displays recent pipeline runs. Because each run includes multiple execution stages, test results, and downloadable artifacts, developers might fear that a monolithic index query would be too slow. To make the initial index load instantly, the view renders a self-loading Turbo Frame for each pipeline run, pointing to its respective `show` endpoint:
- **With 20 runs per page via Turbo Frames**: A single user visit triggers **21 HTTP requests**, runs **81 SQL queries**, and consumes roughly **110 milliseconds** of server compute time. Application Performance Monitoring (APM) tools report every individual request as green and lightning-fast, masking the aggregate drag on the server. - **Traditional Rails rendering without frames**: Querying the runs using ActiveRecord eager loading (`includes`) renders the entire page in **1 HTTP request**, executes **3 SQL queries**, and completes in **30 milliseconds**.
The Turbo Frames implementation executes more than triple the server workload for the exact same UI outcome. Switching frames to lazy loading (`loading="lazy"`) only defers requests until elements approach the viewport. Depending on screen dimensions, device resolution, and user scroll depth, lazy loading may slightly reduce client-side rendering bottlenecks, but it frequently trades client perception for bloated server utilization and makes root-cause database performance issues substantially harder to isolate.
---
[18:13](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1093s) The Inline Editing Dead End: When Frames Outgrow Their Bounds
Inline editing represents the quintessential Hotwire demo: a show template wraps a record's attributes in a Turbo Frame, a click swaps the region for an edit form wrapped in a matching frame, and submitting the form updates the server and swaps the frame back to the updated show view—all without custom JavaScript or full-page reloads.
Jeremy sets up a scenario around an account setup wizard where users must complete various profile fields before making their profile public. Using Turbo Frames for inline section-by-section editing feels ideal initially. However, analytics eventually reveal that users are abandoning the onboarding flow midway through. Product management asks for two additions: 1. A persistent completion checklist in the sidebar indicating remaining setup steps. 2. A progress bar in the top navigation displaying the exact percentage of profile completeness.
When users edit and save an account section, the localized Turbo Frame updates its own inputs perfectly, but the sidebar checklist and header progress bar remain frozen until the user performs a full browser reload.
This highlights the classic "Turbo Frames dead end." Frames operate under a strict constraint: one request can only update a single, predefined region of the DOM. As soon as a user action requires coordinated changes across disjointed areas of a layout that cannot be wrapped in a single parent frame, Turbo Frames fall short.
Jeremy advises against reaching for awkward workarounds, such as embedding Turbo Streams inside frame responses or attaching Stimulus controllers that listen for frame loads to fire secondary `fetch` requests. Instead, developers should recognize when they have outgrown frames and step up to Turbo Streams, which allow a single response to target and update multiple disparate DOM IDs simultaneously. As Jeremy summarizes:
> "Frames make you decide what a controller action should update before you know. Streams let the action decide."
---
[21:03](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1263s) Flight Four: Turbo Streams and DOM ID Contracts
Flight four introduces Turbo Streams, Hotwire’s dedicated protocol for delivering granular HTML mutations to an active web page. Acting as the spiritual successor to Rails' Server-Generated JavaScript Responses (SJR or `create.js.erb` templates), Turbo Streams package atomic actions—such as `append`, `prepend`, `replace`, `update`, and `remove`—paired with target DOM IDs and template payloads.
Turbo Streams typically reach the browser through two delivery channels: - Directly over traditional HTTP responses (such as responding to a form submission). - Asynchronously broadcast over WebSockets (via ActionCable) or Server-Sent Events (SSE).
The primary trap with Turbo Streams stems from their silent failures. Hotwire relies fundamentally on DOM IDs, and Turbo Streams demand strict coordination between the target ID specified in the server's stream action and the rendered ID present in the client DOM. If a target ID is misspelled, out of sync, or missing from the page, Turbo silently drops the update without throwing clear visual warnings.
[22:09](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1329s) Increasing DOM Target Specificity in Turbo Streams
```mermaid flowchart TD accTitle: Resolving Ambiguous Turbo Stream Targets accDescr: Demonstrates how generic DOM IDs cause collisions when multiple UI components represent one model instance, and how DOM target solves it with scoped IDs.
subgraph Generic["Generic DOM ID (Fails)"] A["Task Model Instance"] -->|"dom_id(task)"| B["#task_42"] B -.->|"Target conflict"| C1["Task Row in List"] B -.->|"Target conflict"| C2["Task Pin Card in Sidebar"] end
subgraph Specific["DOM Target Helper (Succeeds)"] D["Task Model Instance"] -->|"dom_target(task,:row)"| E1["#task_42_row"] D -->|"dom_target(task,:pin)"| E2["#task_42_pin"] E1 --> F1["Updates Task Row"] E2 --> F2["Updates Sidebar Pin Card"] end ```
Turbo Streams can fail silently when targeting DOM elements. Unlike Turbo Frames, which provide explicit visual cues or warnings when something goes wrong, "there are no server-side or client-side warnings to let you know that a stream is targeting the wrong ID or even a non-existent one. In other words, there's no content missing equivalent for streams." If a target ID is misspelled or ambiguous, the stream simply drops its payload into the void without throwing an error.
This breakdown becomes painfully clear in common UI designs, such as a task manager featuring a main list, an "Up Next" sidebar showing pinned tasks, and a button to clear completed items. Rails developers instinctively reach for `dom_id(task)` to identify elements. However, if a single task is represented in two places on the page—as a standard row in the list and as a pinned card in the sidebar—using `dom_id(task)` creates identical element IDs. When that task is marked complete, the stream needs to update both components simultaneously, but targeting a non-unique ID breaks client-side manipulation.
The solution requires abandoning the assumption that an element ID must represent the model instance canonical form. Instead, developers should increase specificity by identifying the role of the component, differentiating between a task row and a task pin card. While prefixing with `dom_id` is an option, Jeremy recommends using the newer `dom_target` helper. This helper allows developers to assemble clear, composite element IDs from an arbitrary collection of class names, model instances, symbols, and strings, ensuring exact, conflict-free Turbo Stream targeting.
- `dom_id(task)` generates an identical target for every visual representation of the same record on a page. - Turbo Streams provide neither client-side console warnings nor server exceptions when targeting missing or duplicate DOM elements. - The `dom_target` helper accepts arrays combining model instances, symbols, and strings to generate unambiguous DOM IDs (e.g., separating `:row` from `:pin_card`).
---
[23:49](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1429s) Decoupling Broadcasts: Who, What, and When
```mermaid flowchart LR accTitle: Scoped Broadcast Isolation Architecture accDescr: Shows how moving broadcasts from Active Record callbacks to operational contexts prevents cross-tenant data leaks and thundering refreshes.
subgraph AntiPattern["Coupled Callbacks (Leaky)"] M1["Order Model"] -->|"after_create"| G1["Global Order Stream"] G1 --> C1["Cafe A (Bar / Pickup)"] G1 --> C2["Cafe B (Bar / Pickup)"] end
subgraph IsolatedPattern["Decoupled Contexts (Isolated)"] OP["Mark Ready / Import Operation"] --> M2["Order#broadcast_ready"] M2 -->|"Scoped to cafe_id"| S1["Cafe A Stream"] M2 -->|"Scoped to cafe_id"| S2["Cafe B Stream"] end ```
Moving into the spiciest category of Turbo Streams—asynchronous broadcasts—requires extreme caution ("you need to be really careful around the eyes"). Consider a cafe ordering application where baristas input and prepare drink orders on a prep station while customers watch an order-ready pickup screen. The application maintains an `Order` model with two partials: a table row for the baristas and a pickup card for the public pickup board. While Turbo Rails provides convenient one-line macros for basic model broadcasting, displaying orders only when marked ready requires writing custom Active Record callbacks.
This architecture quickly falls apart as soon as a second cafe joins the platform. Because both cafes subscribe to un-scoped global streams, order updates and completion events leak across accounts—meaning Cafe A sees orders belonging to Cafe B. Additionally, introducing bulk mobile order imports triggers an avalanche of individual lifecycle callbacks, queuing separate background jobs and broadcasting 30 separate single-row updates instead of one unified payload.
Resolving this architectural flaw requires answering three questions: who, what, and when: 1. **The Who:** Streams must be isolated by tenant and interface. Instead of sharing a global channel, cafes require distinct channels scoped by tenant ID and audience (e.g., `[cafe,:bar]` for staff and `[cafe,:pickup]` for customers). 2. **The When:** Active Record models must lose the privilege of deciding *when* to broadcast. Hooking broadcasts into `after_create` or `after_update` lifecycle hooks commits the system to side effects that backfire during batch operations—much like the classic mistake of triggering welcome emails via `after_create`, which inevitably results in a "company apology when 10,000 imported users are accidentally sent that signup confirmation email." 3. **The What:** The model should only retain broadcast composition logic (such as an explicit `broadcast_ready` method), while distinct application operations or services decide when to trigger delivery. Decoupling these concerns allows a bulk import to send a single stream message carrying 30 items instead of 30 individual messages.
Turning to Turbo 8's broadcast refreshes might seem like an easy alternative, but broadcasting refreshes globally across a cafe introduces major scalability risks. Because each refresh broadcast instructs every connected client to initiate a full-page HTTP request, high-concurrency environments quickly run into "thundering refreshes"—overwhelming the application servers with simultaneous page requests.
- Global stream names leak sensitive data across multi-tenant boundaries; streams must be scoped explicitly to their tenant and target context. - Hardcoding stream broadcasts into Active Record lifecycle callbacks causes unwanted side effects during seeds, console operations, and batch imports. - Morphing refresh broadcasts trigger one full HTTP page render per connected client, risking performance degradation under heavy user loads.
---
[26:52](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1612s) "The Last Dab": Client-Side Personalization for Global Streams
```mermaid flowchart TD accTitle: Client-Side Personalization of Broadcast Streams accDescr: Demonstrates how a generic broadcast rendered outside of request context is personalized on the client using browser state and meta tags.
subgraph Server["Server Broadcast (No Request Context)"] BG["AI Background Job"] -->|"Renders generic partial"| BC["Turbo Stream Broadcast"] BC -->|"Payload: UTC Timestamp + Player ID"| P1["Player 1 Browser"] BC -->|"Payload: UTC Timestamp + Player ID"| P2["Player 2 Browser"] end
subgraph Client["Client-Side Stimulus Personalization"] P1 --> ST1["Stimulus: Local Time Controller"] P1 --> SM1["Stimulus: Player Tag Matches Meta?"] SM1 -->|"Matches Current Player"| R1["Renders: 'You'"]
P2 --> ST2["Stimulus: Local Time Controller"] P2 --> SM2["Stimulus: Player Tag Matches Meta?"] SM2 -->|"Different ID"| R2["Renders: 'Player 1'"] end ```
The final challenge—analogous to Hot Ones' infamous "Last Dab"—surfaces when trying to personalize real-time broadcasts. The problem is demonstrated through "20 Agentic Questions," a multiplayer guessing game where two players in different time zones take turns interrogating an AI agent. Because the AI takes several seconds to evaluate questions, the application cannot render responses synchronously within a controller action. Instead, the controller broadcasts an initial "agent is thinking" notification, dispatches a background job to process the query, and ultimately broadcasts the final yes/no response to both players over WebSockets.
Two critical bugs emerge in this setup: 1. The UI is designed to show relative labels ("You" or "Your") for the active user. When Player 1 submits a guess, the controller renders the partial using Player 1's `Current.player` request context. That rendered HTML is then broadcast verbatim to both players, meaning Player 2's screen also reads "You" for Player 1's action. 2. The agent background job crashes with an `ActionView::TemplateError`. When the background worker attempts to render the timestamp partial using the current player's time zone, it fails completely because "there is no `Current` in a background job." Rendering views outside of an HTTP request means `Current.player` is `nil`, breaking the view and causing the broadcast job to fail.
While segmenting streams per user could work for a two-player game, generating personalized renders per subscriber fails to scale to thousands of users. The optimal architecture depersonalizes the server payload and leaves personal adjustments to the client: - **Server Broadcasts Canonical Data:** The partial must be rendered without reference to request-specific state, outputting neutral data such as raw UTC timestamps and unique identifiers (e.g., `player_id`). - **Browser State Adaptation:** The client leverages information the browser already possesses. A Stimulus `local-time` controller reads the UTC timestamp upon connecting and translates it into the visitor's local system time zone. - **Client Identity Comparison:** To re-establish relative identities ("You" vs. "Player Name"), the layout exposes an identity anchor—a `<meta name="player-key">` tag containing the logged-in user's identifier. A custom Stimulus controller compares the broadcast payload's player key against this meta tag; if the identifiers match, the controller rewrites the element label from the third-person player name to "You."
By keeping server broadcasts generic, a single Action Cable message can be distributed efficiently to thousands of open connections while lightweight Stimulus controllers handle client-side personalization.
- Partial templates rendered inside background jobs cannot access request-scoped globals like `Current.user` or session state. - Broadcasting personalized HTML directly from the server requires multiple renders and channel segments, which scales poorly. - Combining UTC timestamps with a client-side Stimulus controller ensures accurate time formatting without server-side time zone rendering. - Embedding session identifiers within HTML meta tags allows client-side Stimulus controllers to dynamically inject personalized text like "You."
---
[31:21](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1881s) Architectural Balance and Wrap-Up
Jeremy concludes by reflecting on the broader philosophy of frontend architecture within Ruby on Rails. A successful framework needs "enough rigidness to provide familiarity and comprehensibility for everyone using it across projects, but also enough flexibility to allow for house styles to emerge." The design patterns covered throughout the spicy hot-sauce tour represent his own practical house style, offered as an ongoing contribution to Rails culinary traditions.
Developers should not shy away from pushing Hotwire to its limits: "keep taking those bites and feeling the burn. That's how we build that judgment. But remember to keep most things mild so you're not sweating it out every workday." Building robust applications means knowing when to rely on default, low-complexity patterns and reserving intricate broadcasting and client adaptations for the specific cases that truly need them.
- Complete slide decks, code examples, and curated Hotwire resources are hosted at `practicalhotwire.com`. - Jeremy invites teams needing hands-on engineering help with Rails or Hotwire applications to reach out to him directly.
## Notable quotes > [00:11](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=11s) Take a bite and feel the burn. — Jeremy Smith
> [00:11](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=11s) Yeah, but how many in that has_many? — Jeremy Smith
> [03:08](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=188s) Hotwire is trying to help us write less JavaScript, and that usually means writing more HTML. — Jeremy Smith
> [05:38](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=338s) Can and should the server be doing this work? — Jeremy Smith
> [12:19](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=739s) People tend to love this label and they don't even notice that they're sweating. — Jeremy Smith
> [15:48](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=948s) what feels like a quick perf win can come back to bite you if you forget that each frame load is a separate request. — Jeremy Smith
> [18:13](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1093s) Frames make you decide what a controller action should update before you know. Streams let the action decide. — Jeremy Smith
> [22:09](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1329s) there are no server-side or client-side warnings to let you know that a stream is targeting the wrong ID or even a non-existent one. In other words, there's no content missing equivalent for streams. — Jeremy Smith
> [23:49](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1429s) company apology when 10,000 imported users are accidentally sent that signup confirmation email. — Jeremy Smith
> [26:52](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1612s) there is no Current in a background job. — Jeremy Smith
> [31:21](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1881s) enough rigidness to provide familiarity and comprehensibility for everyone using it across projects, but also enough flexibility to allow for house styles to emerge. — Jeremy Smith
> [31:21](https://www.youtube.com/watch?v=ejOofhbYB3Q&t=1881s) keep taking those bites and feeling the burn. That's how we build that judgment. But remember to keep most things mild so you're not sweating it out every workday. — Jeremy Smith
## Mentioned Jeremy Smith, Rails World, Hot Ones, Austin, Texas, Ruby on Rails, Hotwire, Stimulus, Turbo, Turbo Native, Turbo Drive, Turbo Frames, Turbo Streams, Turbolinks, Flatpickr, Sentry, Bugsnag, ActionCable, Server-Sent Events (SSE), Shopify, practicalhotwire.com
## Video description Jeremy Smith leads a hot-sauce tasting tour of the 12 most common ways to get burned by Hotwire, three each for Stimulus, Turbo Drive, Turbo Frames and Turbo Streams. Along the way he covers composable Stimulus controllers, prefetching and cache snapshots, "content missing" errors, and N+1 frame loads. He also shows how to scope and personalize Turbo Stream broadcasts so they stay correct as your app grows.
Slides found here: https://speakerdeck.com/jeremysmithco/practical-hotwire-stimulus-and-turbo-on-the-ground
***
Thank you Shopify for sponsoring the editing and post-production of these videos. Check out insights from the Engineering team at: https://shopify.engineering/
#RailsWorld #RubyOnRails #Hotwire #Turbo #Stimulus #Frontend
[Source](https://www.youtube.com/watch?v=ejOofhbYB3Q)