Jeremy Smith Shows Hotwire Defaults Overload Rails Infrastructure

Practical Hotwire: Stimulus & Turbo on the Ground - Jeremy Smith — Ruby on Rails

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["&lt;form action='/subscribers' method='post'&gt;"] Textarea["&lt;textarea name='emails'&gt;"] CheckBtn["&lt;button formaction='/subscribers/preview'&gt;Check&lt;/button&gt;"] SubmitBtn["&lt;button type='submit'&gt;Add&lt;/button&gt;"] PreviewTarget["&lt;div id='import_preview'&gt;&lt;/div&gt;"] 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)

中文简报

Jeremy Smith指出Hotwire默认配置会使Rails基础设施过载 若团队将数据流广播直接绑定至ActiveRecord回调,而非隔离操作触发器并将个性化渲染委托给客户端Stimulus控制器,Hotwire应用可能会面临数据库性能劣化及跨租户数据泄露风险。 Jeremy Smith将开发者踩坑的惨痛教训比作攀登史高维尔辣度指标,系统性剖析了Hotwire核心库中的十二个常见陷阱。他展示了如何用可组合的声明式行为取代庞大的单体Stimulus控制器与手动事件监听器,并解释了如何规避Turbo Drive的状态泄露、冻结历史快照以及激进的链接预取等问题。针对Turbo Frames,他探讨了缺失内容的恢复、自加载N+1 HTTP请求带来的性能拖累,以及行内编辑的边界。最后,他概述了稳健的Turbo Stream策略,包括通过dom_target进行精确的DOM定位、将广播发送与ActiveRecord回调解耦,以及利用轻量级Stimulus控制器在客户端对通用的广播载荷实现个性化渲染。 Turbo 8默认的100毫秒悬停预取可能会大幅增加服务器算力消耗与数据库流量,同时引发意外的副作用;团队应考虑通过data-turbo-prefetch="false"进行全局禁用,并采用选择性启用的方式。 (10:26) 自加载Turbo Frames可能会引入严重的N+1 HTTP请求瓶颈:在20次CI流水线运行的基准测试中,自加载frames需要21次请求、81次查询和110毫秒,而单次预加载请求仅需3次查询和30毫秒。 (15:48) 当目标ID缺失或存在歧义时,Turbo Streams会静默失败且不发出警告;在跨多个UI组件显示记录时,开发者应使用Rails的dom_target辅助方法代替标准dom_id来分配特定角色的ID。 (22:09) 直接从后台任务广播个性化HTML会失败,因为后台worker缺乏请求上下文;实时动态应广播标准UTC时间戳和记录ID,将个性化渲染(如相对时间和“你”标签)委托给客户端的Stimulus控制器处理。 (26:52)

Reading summary

Jeremy Smith argues that effective Hotwire development requires disciplined engineering judgment to overcome subtle architectural traps across Stimulus, Turbo Drive, Turbo Frames, and Turbo Streams. By favoring declarative HTML, composable controllers, and decoupled server-side operations, teams can build interactive Rails applications without accumulating front-end and infrastructure debt. Stimulus: Declarative Actions and Composable Controllers (3:08) Stimulus serves as the primary client-side layer in Hotwire, yet developers frequently recreate single-page application complexities by manually wiring DOM listeners in the connect lifecycle. Smith stresses that Hotwire aims to write less JavaScript by writing more HTML. Standard Stimulus actions cleanly replace manual addEventListener and removeEventListener boilerplate, handling window-level listeners and key filters declaratively. Developers also fall into the trap of building monolithic controllers tailored to specific screens rather than reusable behaviors. For instance, a profile input requiring a character countdown, an auto-resizing textarea, and autosaving should not be bound to a single megacontroller. Splitting functionality into focused utility controllers—such as autosave, autosize, and character-count—maximizes reuse across views. Similarly, complex client-side parsing (like email validation previews) duplicates backend logic; developers should instead use standard HTML formaction attributes to let Rails validate payloads and return Turbo Streams directly. Turbo Drive: Lifecycles, Cache Snapshots, and Prefetching (8:01) Because Turbo Drive replaces document bodies without full browser reloads, persistent JavaScript runtimes can leak state. Initializing third-party widgets like Flatpickr in connect without destroying them in disconnect causes duplicate, inert widgets to pile up across history navigations. Furthermore, Turbo Drive caches DOM snapshots for instant back/forward rendering, which can inadvertently freeze transient UI elements like flash banners or open modal dialogs into history. Mitigating this requires applying data-turbo-temporary attributes, resetting elements on disconnect, or configuring no-cache directives. Additionally, Turbo 8's default link prefetching can strain backend infrastructure. Triggering a fetch after a 100ms hover improves perceived latency on clicked links, but it frequently triggers server-side rendering for hovered links that are never visited. For endpoints with unoptimized queries or accidental side effects (such as incrementing view counters), Smith recommends disabling prefetching globally via data-turbo-prefetch="false" on the body tag and opting in selectively only for high-value links. Turbo Frames: Handling Drift, Request Bloat, and Scope (12:19) Turbo Frames isolate page regions by matching frame IDs between client requests and server responses. When refactored views drop matching frame tags, or when endpoints return unauthenticated 302 redirects or 404 error pages, Turbo displays a "Content missing" error. Rather than adding arbitrary frame wrappers to global error layouts, Smith recommends using a Stimulus recovery controller on the turbo:frame-missing event to redirect expired sessions or log application errors to monitoring services. Overusing self-loading frames can also degrade backend performance through an N+1 HTTP request pattern. In a continuous integration dashboard example, loading 20 runs via individual frames produced 21 HTTP requests, 81 database queries, and 110ms of server compute. In contrast, standard Rails rendering with ActiveRecord eager loading completed the page in 1 HTTP request, 3 queries, and 30ms. Finally, frames cannot update multiple disjoint layout sections simultaneously; when inline edits require updating external elements like sidebars or progress bars, developers must transition from frames to Turbo Streams. Turbo Streams: Target Specificity and Decoupled Broadcasts (21:03) Turbo Streams deliver targeted DOM mutations, but they fail silently without console errors if a target ID is missing or ambiguous. Relying strictly on dom_id creates targeting collisions when a single record appears in multiple UI contexts, such as a main task list and a pinned sidebar. Using the composite dom_target helper resolves this by producing distinct, scoped IDs for each visual role. For real-time broadcasts, coupling stream publishing to ActiveRecord lifecycle callbacks leaks data across multi-tenant boundaries and triggers thundering updates during bulk imports. Smith asserts that models should define broadcast payloads while distinct operational services govern when and to whom streams are sent. Furthermore, background broadcast jobs cannot access request-scoped globals like Current.user. To scale shared broadcasts without rendering unique HTML for every subscriber, the server should broadcast canonical UTC timestamps and neutral identifiers, allowing lightweight client Stimulus controllers to personalize timestamps and swap generic names for "You" by inspecting local meta tags. Smith concludes that while developing intuition requires experiencing Hotwire's edge cases firsthand, robust applications depend on keeping baseline implementations mild and declarative. High-complexity solutions—such as broad WebSocket broadcasts and custom client personalization—should be reserved strictly for the specific UI scenarios that justify them.

Source quotes

[0:11] Jeremy Smith: Take a bite and feel the burn. [0:11] Jeremy Smith: Yeah, but how many in that has_many? [3:08] Jeremy Smith: Hotwire is trying to help us write less JavaScript, and that usually means writing more HTML. [5:38] Jeremy Smith: Can and should the server be doing this work? [12:19] Jeremy Smith: People tend to love this label and they don't even notice that they're sweating. [15:48] Jeremy Smith: what feels like a quick perf win can come back to bite you if you forget that each frame load is a separate request. [18:13] Jeremy Smith: Frames make you decide what a controller action should update before you know. Streams let the action decide. [22:09] Jeremy Smith: 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. [23:49] Jeremy Smith: company apology when 10,000 imported users are accidentally sent that signup confirmation email. [26:52] Jeremy Smith: there is no Current in a background job. [31:21] Jeremy Smith: enough rigidness to provide familiarity and comprehensibility for everyone using it across projects, but also enough flexibility to allow for house styles to emerge. [31:21] Jeremy Smith: 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.

Original transcript

[0:06] [music] [0:11] Hey, what's going on everybody from [0:14] Rails World in Austin, Texas. I'm Jeremy [0:16] Smith and you're watching Practical [0:18] Hotwire. It's the talk with hot [0:20] questions and even hotter sauces. [0:23] Before we get started, I'm going to put [0:24] you in the hot seat for a moment. If you [0:27] consider yourself a seasoned Rails [0:28] developer, I'd like you to think back. [0:33] When did you learn not to send an email [0:36] in an afterre call back? [0:39] Why does seeing a third party API call [0:41] call in an controller action make your [0:43] stomach upset? [0:46] How do you know to use timecurren [0:48] instead of time now? [0:51] What makes you nervous about seeing [0:53] instant instance variables in a shared [0:55] partial? [0:58] When you see a dependent uh destroy, why [1:01] are you wondering, "Yeah, but how many [1:02] in that has many?" You might label these [1:05] things judgment. And if you're like me, [1:06] it's not something you were born with. [1:08] You developed it through an ongoing [1:10] process I like to call. Take a bite and [1:12] feel the burn. [1:15] Take a bite when you write some code. [1:16] Feel the burn when you deploy to [1:18] production. [1:20] Take a bite when you architect a [1:21] subsystem and then feel the burn when [1:22] you come back a year later and you can't [1:24] understand it. [1:26] Take a bite when you try a technique [1:28] that you found in a blog post and then [1:30] feel the burn when you discover it only [1:31] works well in one situation and not the [1:33] one that you're in. [1:35] Trying things is the only way to build [1:37] up a tolerance to heat. And having a [1:39] little spice in your diet is uh [1:42] exciting, but for your daily diet, you [1:45] want to keep it mild to avoid that [1:46] perpetual heartburn. [1:49] While you might know what can burn you [1:50] in Rails, do you know what can burn you [1:52] in Hotwire? We're going to find out. But [1:55] first, let's set the table. [1:58] Hotwire is a front-end toolkit favoring [2:00] minimal JavaScript, serverside [2:02] rendering, and HTML payloads. It's ideal [2:04] for back-end developers who want to [2:06] deliver interactive experiences without [2:08] the overhead of complex front-end [2:10] frameworks. [2:13] The toolkit is made up of three parts. [2:15] stimulus for wiring JavaScript to HTML. [2:18] Turbo for page changes and native for [2:21] mobile apps. [2:23] Turbo breaks down into three more parts. [2:26] Drive for full page navigation, frames [2:29] for page regions, and streams for [2:30] surgical updates. [2:33] Native is off our menu today, but we'll [2:35] cover stimulus as well as turbo drive [2:37] frames and streams. Sampling three ways [2:39] to get burnt by each. That's four [2:42] flights, 12 hot sources, and one [2:44] framework that is absolutely fired. [2:48] Flight one stimulus lets you add [2:51] behavior to your existing HTML with a [2:53] JavaScript controller class that wires [2:55] up HTML elements as targets, triggers [2:58] behavior with actions, and then manages [3:00] lightweight state with values. With Hot [3:03] Wire, stimulus is where you'll be [3:04] writing 90% of your JavaScript. [3:08] We'll start with a mild flavor that [3:10] might have you yawning if you're a [3:11] hotwire expert, but it's one that I come [3:13] across surprisingly often, especially in [3:15] code written by unguided agents. [3:20] Let's say we need to implement a simple [3:22] drop- down menu that will toggle show [3:24] hide on button click, but also hide on [3:27] click outside and on escape key press. [3:34] In our JavaScript, we might assume we [3:36] need to set up event listeners to handle [3:39] all each of these inputs, which means [3:41] wiring them up correctly on connect and [3:43] tearing them down on disconnect. But [3:45] stimulus actions can give us all those [3:47] listeners for free. Remember, hot wire [3:49] is trying to help us write less [3:51] JavaScript, and that usually means [3:52] writing more HTML. [3:55] Here we can use actions to automatically [3:57] give us listeners on specific events or [4:00] elements or even for global events as in [4:02] this case where we need to hide the menu [4:04] if there are clicks outside the [4:06] controller scope. [4:09] Heating up a bit. This one lacks flavor [4:11] and tends to drown the plate. [4:16] Let's say we are tasked with uh adding a [4:18] personal bio field with a 280 character [4:20] limit uh on a user profile page and the [4:23] spec calls for showing the character [4:25] count, autosizing the text area and [4:28] autosaving the form. Our first instinct [4:31] might be to create a single stimulus [4:33] controller to wrap all that [4:34] functionality named after the specific [4:36] usage. [4:39] So, profile biocontroller on the form [4:42] element that updates the counter, [4:44] resizes the text area, and schedules [4:46] save on input change. Don't try to read [4:48] all that. [4:52] But we'll likely need these distinct [4:54] behaviors elsewhere in the future. For [4:56] example, other text areas that need the [4:58] auto size but not the character count, [5:00] or other forms that need to autosave but [5:02] don't have a text area. Coupling all [5:05] three with this specific component makes [5:08] reuse difficult or impossible. [5:11] Stimulus works best when controllers [5:12] implement general behaviors that can be [5:14] composed together for specific uses. So [5:18] instead of a profile bio controller, we [5:21] could have an autosave controller on the [5:23] form, an auto size controller on the [5:26] text area, [5:30] and a character count controller on a [5:32] div that wraps the text area and the [5:34] counter markup. [5:38] If you're a fan of Hotwire, you [5:40] presumably appreciate server side [5:41] rendering. But another frequent burn is [5:43] writing client side code that could have [5:45] been server side. [5:48] A tiny sample of this is constructing a [5:50] URL in JavaScript when the server could [5:52] hand it to you as a stimulus value. [6:00] Sometimes this takes the form of a large [6:02] feature. For example, imagine we need to [6:05] allow users to import subscribers to a [6:07] mailing list by pasting email addresses [6:08] into the text area. We want them to be [6:11] able to validate the list before [6:13] actually attempting to sub add the [6:14] subscribers. So we give them one button [6:17] to check and a second button to add with [6:19] feedback about validity and [6:20] dduplication. [6:22] We might assume that the checklist [6:24] functionality needs to be implemented [6:26] client side in this case because we [6:27] aren't persisting anything at first. And [6:30] so we create a hefty stimulus controller [6:32] to parse, validate, and dduplicate the [6:34] text area contents and then report the [6:35] results back. But that same parsing, [6:38] validation, and dduplication will still [6:40] need to be done on the server side. [6:43] And now we're going to have to maintain [6:45] two implementations of the same logic. [6:47] Once in JavaScript and once in Ruby. So [6:50] why not let the server do more for us in [6:52] this case [6:55] and create an import preview endpoint [6:57] that does the parsing work on the back [6:58] end and then stream the preview results [7:00] back to the page prior to adding the [7:02] subscribers. [7:04] We can even use the same form [7:08] setting an alternate form action on the [7:09] checklist button for our preview [7:10] endpoint and adding an empty div to uh [7:13] receive the preview results. [7:16] This one can be hard to spot the shape [7:19] of and sometimes it's more about [7:20] trade-offs than a genuine flaw. But one [7:22] question to always be asking with [7:24] hotwire is can and should the server be [7:26] doing this work? [7:33] All right, f uh flight two turbo drive. [7:36] Turbo drive is a mostly set it and [7:38] forget enhancement to page navigation [7:40] where intercepts link clicks and form [7:42] submissions, makes a fetch request on [7:44] your behalf, then swaps the body and [7:45] merges the head without requiring full [7:47] page reloads. Drive is the evolution of [7:50] turbo links and while you mainly just [7:52] enable and configure it, the important [7:54] part is understanding the paradigms to [7:56] avoid it surprises. [8:01] This first sample is a blend between [8:03] this flight and our last and can leave [8:05] you bewildered if you aren't aware of [8:06] the stimulus life cycle. Since Drive is [8:09] enhancing navigation by not requiring [8:11] full page reloads, tearing down [8:13] JavaScript behavior on page transition [8:15] or even just when elements are removed [8:16] from a page is often forgotten. Though [8:18] sometimes there's a visual indication as [8:20] with the following case. [8:24] Let's say we need to add a date picker [8:25] to a form input. So we create a stimulus [8:27] controller and import the third party [8:29] flatp picker library and we wire it up [8:31] to the input on connect. [8:33] But then we discover that when we load [8:35] the page and navigate away and then come [8:37] back now there's a new date picker and [8:40] the old one still rendered but no longer [8:41] functioning. In fact there's a new date [8:44] picker every time we navigate away and [8:46] return. [8:48] What we need to do is clean up the [8:49] flatpicker instance on stimulus [8:51] disconnect which covers any time that [8:52] input is removed from the page whether [8:54] due to page navigation or explicit [8:56] removal. [9:00] This is a familiar flavor that sneaks up [9:03] and hits you right in the back button. [9:06] When leaving a page, Drive takes a [9:07] snapshot which it will use both for [9:09] navigating by history with the browser's [9:11] back and forward buttons and for take a [9:14] temporary preview uh during standard [9:16] navigation. When that snapshot is taken, [9:18] we want to leave the page in a state [9:20] that makes sense if it was used on [9:22] return. [9:25] Say we have a settings page that shows a [9:28] flash message on form saves and has a [9:30] confirmation on dangerous actions. If we [9:32] trigger those page changes and then [9:34] visit another page and then come back, [9:35] ideally we wouldn't see that temporary [9:37] state. There are a few techniques that [9:39] we can uh use to make sure that our page [9:42] is ready for cache snapshot on [9:43] navigation. [9:45] First, we can use the data turbo [9:46] temporary attribute on the flash element [9:49] and Drive will automatically remove it [9:50] from the page before caching. [9:53] Second, as with the previous example, we [9:56] can use the stimulus disconnect to make [9:58] sure we tear down that temporary page [10:00] state. [10:04] In this case, our confirmation on the [10:05] dangerous action. [10:07] Failing those two techniques, we can [10:09] listen on the turbo before cache event [10:11] or opt out of caching altogether if [10:13] needed. [10:15] Now, those temporary changes to our UI [10:17] are not included in this cache snapshot, [10:19] and our settings page is pristine upon [10:21] return. [10:26] This innocent looking bottle comes [10:28] standard, but can really pack a punch. [10:30] Turbo 8 shipped with a nice drive [10:32] feature that comes enabled by default, [10:33] but I would argue breaks the principle [10:34] of least surprise. [10:41] If a user hovers on a link for 100 [10:43] milliseconds, Turbo will prefetch that [10:45] page. This can lead to a significant uh [10:48] perceived speed boost for the user, but [10:50] at the cost of many pages render server [10:52] uh rendered and then thrown away as many [10:54] hovers don't lead to a click. [10:57] For apps that haven't already optimized [10:58] serverside page page performance, this [11:00] can be quite expensive. It can also lead [11:02] to unexpected results if get requests [11:05] have side effects such as serverside [11:07] page view tracking, for example. [11:12] Let's say we have a high traffic product [11:13] catalog on our site with individual [11:16] product pages that can be slow and are [11:17] tracking page views uh to indicate [11:19] popularity. [11:21] With turbo defaults, users hovering on [11:23] product cards can lead to many expensive [11:25] requests and incremented page views for [11:27] the potential speed up of just one [11:29] click. This may be a fine default for [11:31] some apps, but my normal recommendation [11:36] is to disable prefetch by default by [11:38] adding data turbo prefetch false to the [11:40] body and then enabling it explicitly in [11:43] the places where the performance gain is [11:45] worth the compute waste. [11:50] In this case, perhaps only prefetching [11:52] the uh most popular items in our shop in [11:54] that top row. [12:02] Sorry. [12:05] Also for side effects like incrementing [12:07] a page view counter, we can look for the [12:09] xsec purpose request header to see if [12:11] it's a prefetch request request and then [12:13] handle it conditionally. [12:19] Flight three, turbo frames. [12:22] Turboroprames is a tool for decomposing [12:24] regions of a page where link clicks and [12:26] form submissions within that region are [12:28] captured and the contents of that region [12:30] are updated based on the response. [12:36] If you worked uh with turbo frames much [12:38] at all, you've probably tasted this one, [12:40] the notorious content missing. When a [12:43] link click or a form submission happens [12:45] inside the context of a frame or is [12:46] targeting that frame, Turbo is expecting [12:48] to receive a response with a matching [12:50] frame ID that it swaps the frame [12:52] contents with. When it doesn't, it will [12:54] display a cont generic contact missing [12:56] message and then throw a Turbo frame [12:58] missing error. There are various reasons [13:00] why a response might not include a [13:02] matching frame ID and not all of them [13:04] are obvious. Imagine we built a [13:07] moderator review queue with a list of [13:09] submissions on the left and a turbo uh [13:11] frame t panel on the right and we'll [13:13] that we'll load the click uh submissions [13:15] into. Most of the time link clicks [13:17] successfully load the submission into [13:19] that panel, but we're occasionally uh [13:21] getting reports that there's content [13:23] missing errors from our user. [13:28] Under the hood, when user clicks on a [13:31] submission, it's going to target the [13:33] submission panel frame ID. When the show [13:36] response comes back, it's going to [13:37] update that frame with the matching [13:38] frame contents without touching anything [13:40] else on the page. It seems simple. So, [13:43] how could this go wrong? Well, there are [13:45] actually two categories of failure. [13:49] First, we may forget to keep that frame [13:51] ID contract over time. [13:54] We might add a new conditional branch or [13:56] partial in the future and not realize a [13:57] wrapping frame was needed. So when a [14:00] request is made for that review Q page, [14:02] there's no frame to swap. [14:04] This might seem really easy to catch [14:06] with a single page, but I promise it's [14:08] much less so when the frames or partials [14:10] are reused. [14:14] Second, we may forget all the other [14:15] responses that a frame request could [14:17] receive. A submission could be archived [14:20] after the review Q loads, leading to [14:22] rendering a 404 page instead with no [14:25] matching frame ID. [14:27] Or a user could be logged out due to [14:29] session inactivity. So that requesting a [14:31] submission returns a redirect to the [14:33] login page, which also lacks that frame [14:35] ID. We probably don't want to go the [14:37] route of including all the possible [14:39] frame IDs in our app on all the possible [14:41] fallback views. [14:44] So instead, we could wrap our submission [14:46] panel turbo frame [14:51] with a div for handling frame recovery [14:53] and a custom fallback message that's [14:55] hidden by default. [15:01] The frame recovery stimulus controller [15:03] intercepts that turbo frame missing [15:04] event. [15:08] It reports the error details to our [15:10] exception tracker and then displays that [15:11] custom error message or performs a visit [15:14] if the response was a redirect. This [15:16] gracefully handles that second category [15:18] as those responses will keep happening. [15:21] But the first category is a real bug [15:23] hidden behind a nicer UX. So reporting [15:25] to our exception tracker tells us what [15:26] needs to be fixed. [15:29] The end result is better better handling [15:31] of edge cases, clearer error messages [15:33] for our users, and alerts about [15:35] actionable bugs for ourselves. People [15:41] tend to love this label and they don't [15:43] even notice that they're sweating. [15:48] One of the killer features of Turbo [15:49] Frames is that a frame can load itself. [15:52] So instead of rendering contents, you [15:54] include an empty frame tag, maybe with a [15:55] loading indicator inside and a source [15:57] attribute pointing at another page. The [16:00] browser fetches it and then fills the [16:01] frame on page load. But what feels like [16:04] a quick PF win can come back to bite you [16:06] if you forget that each frame load is a [16:09] separate request. [16:13] A user landing on one page can fan out [16:15] into requests across many endpoints. [16:17] It's important to recognize when you're [16:19] in an M plus1 situation. [16:24] Let's say we work on a continuous [16:26] integration product building a page that [16:28] lists the latest CI pipeline runs. We're [16:30] concerned it may be slow because each [16:32] run has any number of stages with their [16:34] own results and any number of stored [16:36] artifacts. [16:39] So to make the index load fast, we [16:41] render a turbo frame to run uh to for [16:43] each run and then let it load its own [16:45] contents from the run show endpoint. [16:53] The index view displays 20 runs per [16:56] page. So one user visit is 21 requests. [16:59] 81 SQL queries and about 110 [17:01] milliseconds in total server time. And [17:04] in our APM, every one of those 21 [17:06] requests looks fast. [17:13] But if we tried building the page [17:14] without turbo frames just for comparison [17:17] by using includes on the query and [17:19] rendering each run on the index, [17:22] [snorts] [17:24] it's one request, three queries, 30 [17:28] milliseconds. [17:29] the frames version is doing more than [17:31] three times the server work for the same [17:33] result. Now, we might decide to switch [17:35] our frames to lazy loading so that Turbo [17:37] only makes requests for frames that are [17:39] near the viewport. And then depending on [17:41] the number of pipeline runs in our [17:42] collection, the design of our page, the [17:44] size of the client viewport, and how far [17:46] the user scrolls, we may come out ahead [17:48] or we may not. [17:51] But while the perceived performance for [17:53] the user may be better in some cases, by [17:55] spreading the load across many requests, [17:57] we may pay a much higher cost in server [17:59] utilization and make underlying [18:01] performance problems harder to diagnose [18:03] and resolve. [18:07] This last one can be fine when [18:08] sprinkled, but hits hard with anything [18:10] more. [18:13] The quintessential demo feature for [18:15] Turbo Frames is inline editing. Define [18:18] one or more fields that should be edited [18:19] together. provide a way to toggle to the [18:21] edit mode and then swap the page region [18:23] to a form. [18:26] Under the hood, the frame tag wrapping a [18:28] region of the show template corresponds [18:30] to a matching frame tag wrapping the [18:32] form in the edit template. When a frame [18:35] is requested, only that frame is updated [18:36] in the view. On successful save, it [18:39] renders the frame tag from the show [18:40] template again. [18:42] Suppose we're working on an account [18:44] setup page where users must fill out all [18:46] of the fields in their profile before [18:48] it's made public. [18:49] We use Turbo Frames for inline editing [18:51] so users aren't overwhelmed by a long [18:53] form and can make updates one section at [18:55] a time and everything's great at first. [18:57] No full page reloads, no custom [18:59] JavaScript. [19:01] But later we discovered that most users [19:02] aren't actually finishing their setup so [19:04] their profiles stay hidden. And now the [19:06] business wants to make it abundantly [19:08] clear by adding a checklist to the [19:09] sidebar showing users what's left to do [19:12] and adding a progress bar to the top of [19:14] every page with the percentage of [19:15] completion. [19:25] Oh, went too far. So, we implemented [19:28] visual changes, but then we noticed a [19:30] problem. When users submit updates to [19:33] the account setup forms, our Turbo [19:35] frames update their respective regions, [19:37] but the checklist and progress bar don't [19:39] change. [19:40] If we reload the page, we can see the [19:42] proper changes to both. [19:46] This is the classic Turbo Frames dead [19:49] end. As long as a request only changes [19:52] one predefined region of a page, it's [19:54] fine. But as soon as a request needs to [19:58] change multiple parts of a page that [19:59] can't be wrapped in a single frame tag, [20:02] we're stuck. [20:06] At this point, there's a temptation to [20:08] reach for one-off solutions that will [20:10] increase in direction, [20:14] like embedding streams within a frame [20:15] response or sometimes even adding a [20:18] stimulus controller that listens on for [20:19] frame updates and then triggers [20:21] secondary fetches for other page [20:22] regions. [20:28] But instead, we can back up and fully [20:30] embrace the turbo streams. This is the [20:32] hotwire tool that is made for this job. [20:35] With only slightly more code, our [20:36] account set update uh account updates [20:39] can target the exact elements that need [20:40] to change on the page, whether they're [20:42] in the same region or not. [20:48] And that's the key difference. Frames [20:50] make you decide what a controller action [20:51] should update before you know. Streams [20:54] let the action decide. [20:57] But streams can bring their own own [20:58] heat, which leads us to our final [21:00] flight. [21:03] Flight four, Turbo Streams. Turbo [21:06] Streams is a message format for [21:08] delivering DOM changes to a page. A [21:10] stream includes an action like append, [21:12] replace, or remove, a possible DOM [21:14] target, and typically an HTML payload [21:16] wrapped in a template tag. Streams is [21:19] the spiritual successor to SJR, server [21:21] generated JavaScript responses. If you [21:23] ever remember writing JSERB files, [21:27] streams reach the page in one of two [21:28] ways. as the response to a traditional [21:30] HTTP request which we just saw or [21:33] broadcast async over websocket or server [21:36] sign events. [21:41] You won't notice this one and that's [21:42] kind of the problem. At risk of stating [21:44] the obvious, hotwire relies heavily on [21:47] DOM ids and nowhere is felt more clearly [21:49] with than with turbo streams. [21:58] Using streams, it's up to you to enforce [22:00] the contract between [22:05] the ids that are targeting uh your [22:08] stream actions and the ids of the [22:09] elements on the receiving page. [22:18] There are no server side or client side [22:20] warnings to let you know that a stream [22:22] is targeting the wrong ID or even a [22:24] non-existent one. In other words, [22:26] there's no content missing equivalent [22:28] for streams. [22:30] Imagine we built a page that lets users [22:32] uh manage a task list with a sidebar [22:34] that shows two items is up next and a [22:36] button at the bottom to clear completed [22:38] tasks. [22:41] When implementing a view like this where [22:42] we need to reference unique element ids, [22:45] the typical instinct is to reach for the [22:47] DOM ID helper passing in the model [22:49] instances in this case task. [22:55] But that fails us here because individ [22:57] individual tasks are represented in two [22:59] different ways on this page. When a task [23:02] is completed, we need to update two [23:04] different components and then we're [23:06] stuck. [23:12] The answer is to increase specificity. [23:16] Instead of thinking about components on [23:17] a page as being the canonical [23:19] representation of a model instance, be [23:22] more specific about what they are, a [23:24] task row or a task pin card, for [23:26] example. You can use a prefix with DOM [23:29] ID. [23:32] But my recommendation these days is to [23:34] use the newer DOM target helper which [23:36] allows you to construct IDs out of any [23:38] number of objects whether class names, [23:40] model instances, symbols or strings. [23:44] Now that specificity pays off in [23:46] accurate page updates. [23:49] Our last two samples come from the [23:51] spiciest part of Turbo Streams, async [23:53] broadcast. [23:56] When handling this one, you need to be [23:57] really careful around the eyes. [24:00] Let's say we're building an app for a [24:02] cafe where baristas enter and fill drink [24:04] orders and customers watch a pickup [24:06] board for their name. We have an order [24:09] class with one partial for the order [24:12] rendered on the table on the bar side [24:14] and one for the card on the pickup side. [24:18] The Turbo Rails library gives us a [24:19] oneline macro for broadcasting. [24:22] Now on the pickup side, we are only [24:24] displaying orders when they're ready and [24:26] there's no built-in macro for that. So [24:28] we hand roll a call back to broadcast on [24:30] proper conditions. [24:33] But when we onboard a second cafe to our [24:35] app, we discover a problem. When orders [24:38] are added or mark ready for one cap [24:40] cafe, they up the updates are landing on [24:42] both. And on top of that, we needed to [24:45] add a way to uh bulk import mobile [24:47] orders and every order does its own call [24:49] back, background job, and broadcast, [24:51] which is not ideal. [24:54] If we zoomed out, the overarching [24:55] problem to solve here is who gets what [24:57] and when. And we need to get more [24:59] precise with our answers. [25:02] Currently, cafes are subscribed to the [25:04] same global and orders and pickup [25:05] streams. So create the created and ready [25:07] broadcast leak across cafes. [25:11] Instead, we need two broadcasting [25:12] contexts scoped to the respective cafe. [25:16] The bar for baristas and the pickup for [25:18] customers. That's the who. As for the [25:21] when [25:26] stream delivery is currently bound to [25:28] the model callback life cycle. It's the [25:32] same thing that makes us worry of [25:33] sending a confirmation email on after [25:35] create of a user. It commits us to a [25:38] side effect that seems fine for the [25:40] normal use case but may eventually lead [25:42] to an ap company apology when 10,000 [25:44] imported uh users are accidentally sent [25:46] that signup confirmation email. [25:50] Our active record model needs to lose [25:52] the privilege of deciding the when by [25:55] dropping those call backs. But it can [25:57] keep the broadcast composition, in this [25:59] case the broadcast ready method, that's [26:01] the what [26:03] and leave each specific operation like [26:05] our order being marked ready to decide [26:07] when to deliver. [26:11] Now we have broadcast isolation between [26:12] our cafes and our bulk import can be a [26:15] single message carrying 30 rows rather [26:18] than 30 carrying one. [26:26] You may be wondering why couldn't we [26:27] reach for broadcast refreshes and the [26:29] truth is we could set broadcast [26:32] refreshes to cafe and every client would [26:35] re-request the full page on order change [26:37] but one refresh broadcast becomes one [26:40] request per connected client. That's [26:43] fine for a few clients, but scaling up [26:44] from there, we're going to get a taste [26:46] that's not on our menu today. thundering [26:48] refreshes. [26:52] And now it's time for the last dab. [26:55] We've built 20 agentic questions, a game [26:57] where two players in different time [26:58] zones take turns guessing what our AI [27:01] agent is thinking of. Since our agent [27:03] take uh can be a little slow and takes [27:05] several seconds to respond to each [27:07] prompt. We can't just render its answer [27:09] in the response to the user's request. [27:11] Instead, on each guess, first we [27:13] broadcast a message stating that our [27:15] agent is thinking, trigger a background [27:17] job to have the agent process the [27:19] question, declare a yes or no verdict, [27:21] and finally broadcast the answer to our [27:23] two players. [27:25] But there seemed to be a few problems [27:27] with our implementation here because we [27:29] wanted the UI to say you or your when [27:32] referring to the loggedin player. It's [27:34] correct when they first load the page, [27:36] but it's not working properly on Turbo [27:37] Stream's broadcast. [27:40] And we're also getting an action view [27:41] template error in the agent background [27:43] job. So what's going on? [27:46] If we take a a look at the game page, we [27:50] have a banner partial at the top that [27:52] displays game state and provides the [27:53] question prompt for the player whose [27:55] turn it is and a question partial that [27:56] renders for each of those question [27:58] records. [27:59] If we drill down to the question [28:01] partial, we see that it renders [28:06] the question created at in the time zone [28:08] of the current player and it labels the [28:10] question with you if the current player [28:12] m matches the player who asked that [28:13] question. When each player loads the [28:16] game page for themselves, the question [28:18] partial properly renders the question [28:20] time zone and label. [28:22] But when one player makes a guess, the [28:25] turbo streams that are rendered and [28:26] broadcast for the from the controller [28:29] action only know about that request. And [28:31] at that point, current player is the [28:33] player that made the guess. So the [28:35] question partial will be rendered for [28:37] that specific player and then stream to [28:38] both. [28:40] To make matters worse, when the agent [28:42] background job runs and tries to [28:44] broadcast turbo streams, it's rendering [28:46] partials outside the context of any [28:47] request. [28:50] There is no current in a background job. [28:53] So when it tries to render the question [28:55] created at in the time zone of the [28:56] current player, it raises an exception. [28:58] The job fails. So the stream updates [29:00] never broadcast. [29:03] At this point, you may be thinking of [29:04] the last example and wondering if we [29:06] could segment our streams by player and [29:08] then render uh different stream updates [29:10] for each. And while that might be a [29:12] reasonable sit uh that might be [29:15] reasonable in this situation where there [29:16] are only two players and we can assume [29:18] that they're both subscribed during the [29:19] game, that approach may not scale well [29:21] if there are thousands of users, many of [29:23] whom won't be subscribed at the time of [29:25] broadcast. [29:28] Instead, we may notice that there are [29:29] really only two differences in the [29:31] stream question partial, and both have [29:33] to do with personalization. [29:36] So, what if we could deliver the same [29:38] partial to each player and then [29:40] personalize on the client side? When the [29:43] bulk of a payload is the same, we get [29:45] high utilization from a single [29:46] serverside rendering with only minor [29:48] adjustments that might be needed when [29:50] the pay payload arrives in each [29:52] connected browser tab. [29:57] First, we need to depersonalize our [29:58] partials so they can be received by [30:00] anyone and ensure that they don't uh [30:02] rely on any state from a request. in [30:04] this case, the current player. Next, we [30:07] need some way to apply that [30:09] personalization on the client side, [30:12] which brings us back around to stimulus [30:14] and a couple techniques we can reach for [30:16] in the browser. Turns out the browser [30:18] knows many there are many things the [30:19] browser already knows about uh personal [30:21] that can help with personalization. For [30:23] example, the current time zone of the [30:26] client. We can create a local time [30:28] stimulus controller and on connect it [30:30] can parse the datetime value and then [30:32] update the UTC time stamp to the user's [30:34] own time zone. And then for things that [30:36] the browser doesn't already know, we can [30:39] ensure that the receiving page already [30:41] has the information necessary to [30:43] personalize. [30:46] We can add a player key meta tag to the [30:48] head of our layout for the unique key [30:50] identifier of the current player. then [30:53] create a stimulus controller that [30:54] compares the value uh incoming from the [30:57] turbo stream with that meta tag and [30:59] update the text contents if they match. [31:01] We can wrap the player's name with a [31:03] controller and if the player key matches [31:05] update the name to you as a label. [31:11] Now the server renders and broadcasts [31:13] what is true for everyone and each [31:15] individual browser makes it true for the [31:17] specific user. [31:21] And with that, the wings of death are [31:23] behind us and there's nothing left to do [31:25] but roll out the red carpet. Let me let [31:27] me uh let me leave us with a couple [31:29] closing thoughts. First, I think a sign [31:32] of a good framework is enough rigidness [31:34] to provide familiarity and [31:36] comprehensibility for everyone using it [31:39] across projects, but also enough [31:41] flexibility to allow for house styles to [31:43] emerge. This tour reflects my own house [31:45] style. Uh, you may disagree on some [31:47] points, but I do hope it's a [31:49] contribution to our culinary traditions. [31:53] Second, I'd encourage you to keep taking [31:55] those bites and feeling the burn. That's [31:56] how we build that judgment. But remember [31:58] to keep most things mild so you're not [32:00] sweating it out every workday. [32:02] And third, here's an a bridged list of [32:04] hotware hotwire resources I'd recommend [32:06] for you. For a full list and the code [32:08] samples from this talk, please check out [32:10] practicalhotwire.com. [32:13] And finally, reach out if you think [32:15] you'd like to work with me on a Rails or [32:16] Hotwire project. I would love the job. [32:18] Thanks. [32:20] [applause] [32:26] >> [music]