Twelve Ways to Get Burned by Hotwire: Jeremy Smith's Practical Tour of Stimulus and Turbo

Open on YouTube ↗
Overview

Jeremy Smith's Rails World talk asks a simple question: experienced Rails developers know what can hurt them in Rails, but do they know what can hurt them in Hotwire? He frames the talk as a hot-sauce tasting with four "flights": Stimulus, Turbo Drive, Turbo Frames, and Turbo Streams. Each flight has three "sauces," which are common mistakes. Smith says these mistakes come from his own experience, and he presents his fixes as his personal "house style," not as fixed rules.

21 min read
0:29

Judgment comes from getting burned

Smith opens by asking seasoned Rails developers to think back. When did they learn not to send an email in an after_create callback? Why does a third-party API call inside a controller action make them uneasy? Why Time.current instead of Time.now? Why are instance variables in a shared partial a worry, and why does dependent: :destroy make them ask how many records sit behind that has_many?

He calls this kind of knowledge judgment. He argues nobody is born with it. It grows through a cycle he calls "take a bite and feel the burn." You write code, then deploy it and feel the burn. You design a subsystem, then come back a year later and can't understand it. You copy a technique from a blog post, then find it only works in a situation that isn't yours. Trying things is the only way to build tolerance, he says. Still, day-to-day code should stay "mild" so you aren't living with constant heartburn.

He then sets out the toolkit. Hotwire is a front-end toolkit that favors minimal JavaScript, server-side rendering, and HTML payloads. It is aimed at back-end developers who want interactive experiences without a heavy front-end framework. Its parts are Stimulus (connecting JavaScript to HTML), Turbo (page changes), and Native (mobile apps). Turbo has three parts of its own: Drive for full-page navigation, Frames for regions of a page, and Streams for targeted updates. Native is left out of the talk.

Flight one: Stimulus

Stimulus adds behavior to existing HTML through JavaScript controller classes. These classes connect elements as targets, trigger behavior through actions, and hold small amounts of state in values. Smith says this is where about 90% of a Hotwire app's JavaScript gets written.

3:14

Hand-wiring event listeners that actions provide for free

Smith calls the first sauce mild and says Hotwire experts may find it boring. He still sees it often, especially in code written by agents working without guidance. His example is a dropdown menu. It toggles on button click and also closes on a click outside or on the Escape key. The instinct is to add event listeners by hand in connect and remove them in disconnect. Stimulus actions already provide those listeners. They can bind to specific events, to specific elements, and to global events. A global listener covers the "click outside the controller" case. His point is that Hotwire is trying to help you write less JavaScript, and that usually means writing more HTML.

4:15

One controller per feature instead of composable behaviors

The second example is a profile bio field with a 280-character limit. The spec asks for a live character count, auto-resizing of the textarea, and autosave. The first instinct is one controller named after its use, such as a profile-bio controller on the form. It would update the counter, resize the textarea, and schedule saves.

Smith says this ties together three behaviors that will likely be needed separately later. Other textareas may need auto-sizing without a counter, and other forms may need autosave without any textarea. In his view, Stimulus works best when controllers provide general behaviors that you combine for specific cases. His alternative is three controllers: an autosave controller on the form, an auto-size controller on the textarea, and a character-count controller on a wrapping div that contains the textarea and the counter markup.

5:40

Client-side code that could be server-side

The third Stimulus mistake is writing in the browser what the server could do. A small version of this is building a URL in JavaScript when the server could pass it in as a Stimulus value. A larger version is a mailing-list import feature. Users paste email addresses into a textarea and get two buttons: one to check the list, with feedback on validity and duplicates, and one to actually add the subscribers.

Since nothing is saved during the check step, it's tempting to write a large Stimulus controller that parses, validates, and de-duplicates the list in the browser. The server still has to do the same work when subscribers are added, though. That leaves two copies of the same logic, one in JavaScript and one in Ruby. Smith's alternative is an import-preview endpoint that parses on the back end and streams the preview results back to the page. The same form can serve both buttons: the check button uses an alternate form action pointing at the preview endpoint, and an empty div receives the results. Smith admits this pattern can be hard to spot and is sometimes more of a trade-off than a real flaw. He says the question to keep asking with Hotwire is whether the server can, and should, be doing this work.

Flight two: Turbo Drive

Smith describes Turbo Drive, the successor to Turbolinks, as a mostly "set it and forget it" upgrade to navigation. It intercepts link clicks and form submissions, makes a fetch request, swaps the <body>, and merges the <head>, all without a full page reload. You mostly just turn it on and configure it. The important part, he says, is understanding its model so it doesn't surprise you.

8:07

Forgetting to tear down on disconnect

This one sits between Stimulus and Drive. Because pages don't fully reload, developers often forget to remove JavaScript behavior when the page changes or when elements are taken out. His example is a date picker. A Stimulus controller imports the third-party flatpickr library and attaches it to an input in connect. After leaving the page and coming back, a second picker appears next to the old one, which still renders but no longer works. Each round trip adds another. The fix is to destroy the flatpickr instance in the Stimulus disconnect callback. That callback runs whenever the input leaves the page, whether through navigation or explicit removal.

9:02

Leaving temporary state in the cache snapshot

When you leave a page, Drive takes a snapshot. It uses that snapshot for back and forward navigation and as a temporary preview during normal navigation. Smith says the page should be in a sensible state when the snapshot is taken. His example is a settings page that shows a flash message after a save and a confirmation prompt on a dangerous action. If a user triggers both, leaves, and comes back, they shouldn't see that leftover state.

He lists the techniques in order of preference. First, put the data-turbo-temporary attribute on the flash element, and Drive will remove it before caching. Second, use Stimulus disconnect to tear down temporary UI like the confirmation. If those aren't enough, listen for the turbo:before-cache event, or opt out of caching entirely. With these in place, the settings page looks clean when the user returns.

10:32

Prefetch on hover

Smith says this "innocent-looking bottle" comes standard but can pack a punch. Turbo 8 shipped link prefetching turned on by default, which he argues breaks the principle of least surprise. If a user hovers over a link for 100 milliseconds, Turbo prefetches that page. Users may see a real speed boost. The cost is that many pages get rendered on the server and then thrown away, since many hovers never become clicks. For apps that haven't already optimized server-side page performance, he says this can get expensive. It can also cause surprises when GET requests have side effects, such as server-side page-view tracking.

His example is a high-traffic product catalog. Product pages can be slow, and page views are counted to measure popularity. With the defaults, users hovering over product cards trigger many expensive requests and inflate view counts, all for the possible speedup of one click. Smith says the default may suit some apps. His usual advice is to turn prefetch off globally with data-turbo-prefetch="false" on the body and turn it back on only where the speed gain is worth the wasted compute. In the catalog, that might mean only the most popular items in the top row. For side effects like view counters, the server can check the X-Sec-Purpose request header to detect a prefetch and handle it differently.

Flight three: Turbo Frames

Turbo Frames split a page into regions. Link clicks and form submissions inside a region are captured, and that region's contents are replaced from the response.

12:38

The notorious "Content missing"

When a link or form inside a frame, or targeting a frame, gets a response, Turbo looks in that response for a frame with the matching ID and swaps in its contents. If it can't find one, it shows a generic "Content missing" message and throws a turbo:frame-missing error.

Smith's example is a moderator review queue. Submissions are listed on the left, and a Turbo Frame panel on the right loads whichever submission is clicked. Most clicks work, but users occasionally report "Content missing." He describes two kinds of failure. The first is breaking the frame-ID contract over time. Someone adds a conditional branch or partial and doesn't realize it needed the wrapping frame, so the response has no frame to swap. He notes this is easy to catch on a single page but much harder when frames and partials are reused. The second is forgetting all the other responses a frame request can get. A submission might be archived after the queue loads, so the app renders a 404 with no matching frame. A session might expire, so the request redirects to the login page, which also has no frame.

Adding every possible frame ID to every fallback view isn't practical. Instead, Smith wraps the panel's frame in a div with a frame-recovery Stimulus controller and a custom fallback message that is hidden by default. The controller catches the turbo:frame-missing event, reports the details to the exception tracker, and then either shows the custom message or, if the response was a redirect, performs a full visit. This handles the second kind of failure gracefully, since those responses will keep happening. Smith stresses that the first kind is a real bug hiding behind a nicer UX. The exception reports are what tell you it needs fixing. The result is better handling of edge cases, clearer messages for users, and actionable alerts for developers.

15:43

N+1 frame loads

One of Turbo Frames' best features is that a frame can load itself. You render an empty frame, perhaps with a loading indicator, and give it a src pointing at another URL. The browser fills it in after page load. Smith warns that what feels like a quick performance win can backfire, because every frame load is a separate request. One page visit can fan out into many requests across many endpoints, and you need to notice when you've built an N+1.

His example is a CI product page listing recent pipeline runs. Each run has any number of stages and stored artifacts, and the team worries the page will be slow. So the index renders one self-loading frame per run, each pointing at the run's show endpoint. At 20 runs per page, one visit means 21 requests, 81 SQL queries, and about 110 milliseconds of total server time, and each request looks fast in the APM. For comparison, he builds the same page without frames, using includes on the query and rendering every run in the index. That version takes one request, three queries, and 30 milliseconds. By his numbers, the frames version does more than three times the server work for the same result.

Lazy-loading the frames, so Turbo only fetches those near the viewport, might help or might not. Smith says it depends on how many runs there are, the page design, the viewport size, and how far users scroll. Perceived performance may improve in some cases. But spreading the load across many requests can cost much more in server use and make the underlying performance problems harder to find and fix.

18:09

The inline-editing dead end

Smith calls inline editing the classic Turbo Frames demo. A frame wraps a region of the show template, and a matching frame wraps the form in the edit template. Clicking edit swaps in the form, and a successful save renders the show version again. He says this is fine in small doses but hurts when used heavily.

His example is an account setup page. Users must fill in every profile field before their profile goes public, and inline frames let them edit one section at a time without facing a long form. It works well at first, with no full reloads and no custom JavaScript. Then the team finds that most users never finish setup. The business asks for a sidebar checklist of remaining items and a completion progress bar at the top of every page. After those are built, saving a section updates its frame, but the checklist and progress bar stay stale until a full reload.

Smith calls this the classic Frames dead end. Frames work as long as a request changes one predefined region. Once a request needs to update several parts of the page that can't share one frame, you're stuck. He warns against one-off workarounds that add indirection, such as embedding streams in a frame response, or a Stimulus controller that watches for frame updates and fires extra fetches for other regions. His advice is to step back and fully adopt Turbo Streams, which he calls the Hotwire tool built for this job. With only slightly more code, account updates can target exactly the elements that need to change, wherever they are on the page. He sums up the difference this way: Frames make you decide what a controller action should update before you know, and Streams let the action decide.

21:09

Flight four: Turbo Streams

Turbo Streams are a message format for delivering DOM changes. Each stream has an action such as append, replace, or remove, usually a target, and typically an HTML payload in a <template> tag. Smith calls Streams the spiritual successor to server-generated JavaScript responses (the old .js.erb files). Streams arrive in one of two ways: as the response to a normal HTTP request, as in the account setup example, or as an asynchronous broadcast over WebSockets or server-sent events.

21:43

Silent DOM ID mismatches

Hotwire depends heavily on DOM IDs, and Smith says Streams show this most clearly. Keeping stream targets in sync with the IDs on the receiving page is entirely up to you. There are no server-side or client-side warnings when a stream targets the wrong ID or one that doesn't exist. In his words, there is no "Content missing" equivalent for streams.

His example is a task-list page with a sidebar showing two "up next" items and a button to clear completed tasks. The usual instinct is to use the dom_id helper with the task instance. That fails here because one task can appear in two places on the page, so completing it needs updates to two different components. Smith's answer is more specificity. Stop treating a page element as the representation of a model, and name what it actually is: a task row or a task pin card, for example. You can add a prefix to dom_id, but his current recommendation is the newer dom_target helper. It builds IDs from any number of parts, including class names, model instances, symbols, or strings.

23:55

Scoping broadcasts: who, what, and when

Smith says the last two examples come from the spiciest part of Streams, asynchronous broadcasts. The first is a cafe app. Baristas enter and fill drink orders, and customers watch a pickup board for their names. An Order class has one partial for the row in the baristas' table and another for the card on the pickup board. Turbo Rails offers a one-line macro for broadcasting. The pickup board only shows ready orders, and no built-in macro covers that, so the team writes a callback that broadcasts under the right conditions.

When a second cafe joins the app, updates for one cafe start showing up at both. A new bulk import for mobile orders makes things worse, because every imported order runs its own callback, background job, and broadcast.

Smith frames the real problem as "who gets what and when." For who: both cafes subscribe to the same global order and pickup streams, so broadcasts leak between them. They need two broadcasting contexts scoped to each cafe, one for the bar (baristas) and one for pickup (customers). For when: delivery is tied to the model's callback lifecycle. He compares this to sending a confirmation email in a user's after_create. It seems fine for the normal case but can end in an apology when 10,000 imported users all get a signup email by mistake. His fix is to remove the callbacks so the Active Record model no longer decides when. The model keeps the composition of the broadcast, the what, such as a broadcast_ready method. Each operation, like marking an order ready, then decides when to deliver. The result is isolated broadcasts per cafe, and the bulk import can send one message carrying 30 rows instead of 30 messages carrying one row each.

He also answers an expected question: why not use broadcast refreshes? You could broadcast refreshes scoped to the cafe, and every client would re-request the full page on each order change. But one refresh broadcast becomes one request per connected client. Smith says that's fine for a few clients, but as the count grows you get what he calls "thundering refreshes," a problem he leaves for another day.

26:54

Personalized partials and broadcasts

The final example is "20 Agentic Questions," a game where two players in different time zones take turns guessing what an AI agent is thinking of. The agent takes several seconds per prompt, so its answer can't be rendered in the request's response. Instead, each guess broadcasts a "thinking" message, starts a background job where the agent processes the question and returns a yes-or-no verdict, and then broadcasts the answer to both players.

Two problems show up. The UI should say "you" or "your" for the logged-in player. That works on first page load but not in broadcasts. Meanwhile, the agent's background job raises an Action View template error. The game page has a banner partial showing game state and the prompt for whoever's turn it is, plus a question partial for each question record. The question partial shows the creation time in the current player's time zone and labels the question "you" if the current player asked it.

Smith walks through the cause. Streams broadcast from the controller action only know about that request, where the current player is whoever just guessed. So the partial is rendered from that player's point of view and sent to both players. The background job is worse. It renders partials outside any request, so there is no "current" player. Converting the time to the current player's time zone raises an exception, the job fails, and the updates never go out.

Segmenting streams per player, as in the cafe example, might work with exactly two players who are both connected. Smith says it wouldn't scale to thousands of users, many of whom aren't subscribed when a broadcast happens. He notes that the only two differences in the partial are both personalization. His approach is to send the same partial to everyone and personalize it in the browser. When most of the payload is shared, one server-side render serves everyone, and each tab makes small adjustments when the payload arrives.

The first step is to depersonalize the partials so they don't depend on request state like the current player. The second is client-side personalization with Stimulus. For things the browser already knows, such as the client's time zone, a local-time controller parses the datetime value on connect and converts the UTC timestamp to the user's zone. For things the browser doesn't know, the page must already carry the needed data. Smith adds a player-key meta tag to the layout's head with the current player's unique identifier. A Stimulus controller wraps the player's name, compares the incoming player key from the stream with the meta tag, and replaces the name with "you" if they match. The server renders and broadcasts what is true for everyone, and each browser makes it true for its own user.

31:23

Closing thoughts

Smith ends with three thoughts. First, he believes a good framework is rigid enough that anyone using it can recognize and understand a project, yet flexible enough for "house styles" to emerge. This tour reflects his own house style, and he acknowledges listeners may disagree with parts of it. Second, he encourages developers to keep taking bites and feeling the burn, since that is how judgment develops, while keeping most work mild so they aren't sweating every workday. Third, he points to a list of recommended Hotwire resources and says the full list and the talk's code samples are at practicalhotwire.com.