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