JT Book a call

Blog

Turbo 8 Performance Patterns for Real-Time Rails Apps

Author

JetThoughts Team

Published

6 min read

Turbo 8 Performance Patterns for Real-Time Rails Apps
In this post

A common worry about Turbo Streams goes like this: one record changes, a hundred users are watching, so the server renders the partial a hundred times. That is not what happens, and getting this wrong sends you optimizing the wrong thing.

Everything below ran in a scratch Rails 8.1.4 app on Ruby 4.0.6 with turbo-rails 2.0.23, using ActionCable’s test adapter. I counted renders and broadcasts. I did not load-test anything, so there are no latency or capacity numbers here, and I would distrust any post that gives you them without showing its setup.

One stream broadcast is one render #

class Message < ApplicationRecord
  after_create_commit { broadcast_prepend_to "messages", target: "messages" }
end

I counted render_partial.action_view events and ActionCable broadcasts around a single Message.create!. Result: 1 partial render and 1 broadcast on the messages stream. The payload is one ready-made <turbo-stream action="prepend" ...> string. The server renders it once, publishes it, and ActionCable delivers that same string to every connected subscriber. The number of viewers changes the delivery work, not the rendering work.

What does cost you is where that render happens. The header comment of Turbo::Broadcastable in turbo-rails says to use the _later variants for everything except remove, when broadcasting from a controller or model, “since all those updates require a rendering step, which can slow down execution”. I checked that rule. With broadcast_prepend_later_to and the test job adapter, creating a message rendered 0 partials in the request and enqueued one Turbo::Streams::ActionBroadcastJob. The render moves to the job.

after_create_commit { broadcast_prepend_later_to "messages", target: "messages" }

If your Rails request path feels slow after adding broadcasts, this is the first thing to check.

A page refresh is a different cost #

Turbo 8 added a second way to push changes: the refresh stream action. Instead of sending the new HTML, the server sends a signal, and each subscribed page reloads itself. The Turbo handbook describes it as “the server broadcasts a single general signal, and pages smoothly refresh with morphing.”

class Post < ApplicationRecord
  broadcasts_refreshes
end
<%# in the page's view (the layout head must contain: yield :head) %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>

<%# on the page that should update %>
<%= turbo_stream_from @post %>

turbo_refreshes_with puts the two meta tags into the page head with provide :head (so the layout must yield :head; otherwise call turbo_refresh_method_tag and turbo_refresh_scroll_tag directly in the layout head). They are the tags the handbook documents, turbo-refresh-method (morph or replace, default replace) and turbo-refresh-scroll (preserve or reset, default reset). In turbo-rails 2.0.23 it raises ArgumentError on any other value.

Here is the consequence that follows from the design, and I have reasoned it from the definition, not measured it. A stream broadcast costs the server one render. A refresh costs the server nothing at broadcast time, but every subscribed client then requests the page, so a hundred viewers produce a hundred page requests. You pay in requests that hit your normal Rails stack, with its queries and caching, instead of one shared render. Which is cheaper depends on how heavy the page is and how much you can cache. Measure both on your page before you pick.

What a refresh buys is simplicity: you do not write a partial and a target for every change, and the screen cannot drift out of sync with the page because the page is re-fetched.

Morphing keeps state, with limits #

With morph, Turbo does not replace <body>; it updates only the DOM elements that changed, using idiomorph, so the rest of the screen keeps its state. Elements you do not want touched, such as an open popover, get data-turbo-permanent:

<div data-turbo-permanent>...</div>

Turbo frames can opt in to morphing on a refresh with refresh="morph", and then reload their contents instead of being removed.

Refreshes are debounced, but only in one place #

A burst of saves should not become a burst of reloads. I created a Post and updated it three times in quick succession. The result: 1 refresh broadcast on the per-post stream for the three updates, plus 1 from the create on the posts stream.

Reading turbo-rails 2.0.23 shows how. broadcast_refresh_later_to wraps the broadcast in a debouncer with a default delay of 0.5 seconds, and the turbo-rails docs say it debounces “multiple calls in a row to only broadcast the last one”. The debouncer is stored on Thread.current, under a key built from the stream name plus the current request id (turbo-refresh-debouncer-...). So it merges calls for the same stream and request made on the same thread inside that half second. Saves that land on different Puma workers, processes or job threads are not merged, so do not count on this to coalesce a busy multi-process system. The browser side adds its own debounce: pageRefreshDebouncePeriod is 150 ms in Turbo’s session.js (bundled into turbo-rails’ turbo.js).

broadcasts_refreshes itself uses broadcast_refresh_later_to on create and update, and the synchronous broadcast_refresh on destroy.

Lazy frames #

A Turbo frame can wait until it is visible before it loads. From the handbook: frames not visible at first load “can be marked with loading="lazy" such that they don’t start loading until they become visible”, which works like loading="lazy" on images.

<turbo-frame id="activity" src="/dashboard/activity" loading="lazy">
  Loading...
</turbo-frame>

Use it for content below the fold, inside a closed <details>, or in a modal. The handbook adds that frames also improve cache behavior, because a segment that changes on its own schedule or for one audience can be cached separately, and it advises being “judicious” about how many frames you load.

Which to use #

You wantUseServer cost per change
One small piece of the page to change, and you know whichbroadcast_*_later_to1 render in a job, then delivery to every subscriber
The page to match the database without writing update codebroadcasts_refreshes with morphing1 signal, then one page request per client
Slow, below-the-fold content to stay out of the first load<turbo-frame loading="lazy">One request when it becomes visible

What I did not test #

  • Any number of concurrent subscribers. The render counts are per broadcast; the rest is from reading the code and docs.
  • Morphing in a real browser, including how well it preserves form state.
  • Memory use of ActionCable connections, and Redis or Solid Cable behavior under load. See Solid Trifecta for the Rails 8 backends.

Sources #

Related: Stimulus keyboard event filter and debugging Hotwire form failures behind Cloudflare.

Read next

Tell us what you need built

JetThoughts has been building software since 2008. Book a call, or write to sales@jetthoughts.com. You'll leave the call with a written one-page scope within 2 working days.

#01 Paul Keen
Paul KeenFounder & Open Source Contributor

A 30-minute call; pick a time.