Turbo Frames, Streams, morphing, and Stimulus patterns from a Rails monolith at a creator platform. When Hotwire beats a React SPA and the pitfalls that bite at scale.
It was a Tuesday morning at the creator-economy platform I spent the last few years at. Community feeds were the most visible surface we had, served out of the Rails monolith with Turbo Frames around the post list and Turbo Streams pushing new replies live. Around 10:14 a.m. PT, replica lag on our Aurora readers hit 14 minutes. The feed itself was technically up. It was just showing yesterday’s posts as if they were brand new.
Yeah. That was the moment I stopped thinking of Hotwire as a “render trick” and started treating it as a contract with the database underneath it. You can’t out-frame stale data.
I’ve shipped Hotwire on a Rails monolith serving millions of customers. I’ve also shipped plenty of React SPAs and Next.js with TanStack Query, Zustand, the whole stack. Honestly, most of the time the React tax isn’t worth it. If your backend is Rails and your interactions are CRUD plus light real time, Hotwire wins. Cleanly.
Pick Hotwire first. Reach for a React SPA only when you actually have a reason. Real-time canvas tools, offline-first mobile-like flows, multi-user collaborative editing with conflict resolution. Past that line, Turbo plus Stimulus is plenty.
The argument isn’t “React is bad”. I’ve shipped React at every job I’ve had, including a visual app builder for the same creator platform. The point is that most interactive features are list views, filters, modals, forms with validation, and tabs. Hotwire handles every one in less code, with less JavaScript on the wire, and with the rendering logic living next to the data.
A Turbo Frame is a piece of the page that knows how to swap itself. You navigate inside it, the rest of the page stays put. That covers maybe 70% of what people reach for SPAs to do.
<%# app/views/communities/_post.html.erb %>
<%= turbo_frame_tag dom_id(post), class: "post-card" do %>
<article>
<h3><%= post.title %></h3>
<p><%= post.body %></p>
<div class="post-actions">
<%= link_to "Edit",
edit_community_post_path(post.community, post),
class: "btn-secondary" %>
<%= button_to "Like",
like_community_post_path(post.community, post),
method: :post,
class: "btn-primary" %>
</div>
</article>
<% end %>
Edit click navigates the frame to the edit form. Like click posts and the controller re-renders the frame with the new like count. No full page reload. No JS framework. The rest of the feed sits still while one card swaps.
What sells Frames is the controller. Nothing special about it:
# app/controllers/community/posts_controller.rb
class Community::PostsController < ApplicationController
before_action :load_post
def update
if @post.update(post_params)
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace(@post) }
format.html { redirect_to community_post_path(@community, @post) }
end
else
render :edit, status: :unprocessable_entity
end
end
private
def load_post
@community = Community.find(params[:community_id])
@post = @community.posts.find(params[:id])
end
end
Same controller answers HTML and Turbo. No serializer layer, no client-side state slice, no optimistic update reducer. The DB row is the truth and the partial renders it. Refactor the partial, every entry point picks it up for free.
Frames handle the user driving their own session. Streams handle the rest of the world pushing updates in.
# app/models/community/post.rb
class Community::Post < ApplicationRecord
belongs_to :community
belongs_to :author, class_name: "User"
has_many :replies, dependent: :destroy
broadcasts_to ->(post) { [post.community, :posts] },
inserts_by: :prepend,
target: ->(post) { "community_#{post.community_id}_feed" }
end
One line of model config. Every browser subscribed to that stream gets the new post prepended into the feed container the moment the row is committed. The view template is the same partial the controller used for the initial render.
The thing nobody warns you about is the cost of broadcasts. broadcasts_to renders the partial inside the model callback, on the writer’s request thread. If your partial does an unbatched query per post (likes, author avatar, comment count), every insert blocks the writer for the duration of the render plus a hop to Redis. We caught one of these during a hackathon week at the creator platform. Fix was simple. Move the broadcast to a Sidekiq job with eager-loaded associations:
class BroadcastNewPostJob < ApplicationJob
queue_as :realtime
def perform(post_id)
post = Community::Post
.includes(:author, :community, replies: :author)
.find(post_id)
Turbo::StreamsChannel.broadcast_prepend_to(
[post.community, :posts],
target: "community_#{post.community_id}_feed",
partial: "communities/post",
locals: { post: post }
)
end
end
That single change took p99 write latency on community posts from around 800 ms back under 200 ms. The fanout still happened. Just not on the user’s thread.
turbo-rails 2.0 ships morphing as an option on the page level and on individual streams. Instead of swapping the whole frame’s innerHTML, it diffs the existing DOM against the new one and keeps focus, scroll position, video playback state, and form contents that weren’t part of the update.
<%# app/views/communities/show.html.erb %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= turbo_frame_tag "community_#{@community.id}_feed", refresh: "morph" do %>
<%= render partial: "communities/post", collection: @posts, as: :post %>
<% end %>
If a user is mid-scroll, halfway through typing a reply, and a broadcast lands, the reply input doesn’t lose its draft. Cursor stays put. Feed updates around them. This used to be the killer reason to reach for React, and now it isn’t.
The catch is morph is opt-in, not a default. Test it on the real page. Stimulus controllers that mutate DOM imperatively (anything attaching listeners to elements they didn’t own) get confused by morph. If a panel half-disappears after an update, your Stimulus is fighting the morph.
Stimulus is the right size of frontend framework for a Rails app. Sprinkle it. Don’t build a single-page app inside it.
Two patterns I reach for constantly.
// app/javascript/controllers/infinite_scroll_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["sentinel"]
static values = { url: String }
connect() {
this.observer = new IntersectionObserver(
entries => entries.forEach(e => e.isIntersecting && this.load()),
{ rootMargin: "400px" }
)
this.observer.observe(this.sentinelTarget)
}
disconnect() {
this.observer?.disconnect()
}
async load() {
if (this.loading) return
this.loading = true
const res = await fetch(this.urlValue, {
headers: { Accept: "text/vnd.turbo-stream.html" }
})
if (!res.ok) {
this.loading = false
return
}
const html = await res.text()
Turbo.renderStreamMessage(html)
}
}
The endpoint returns a Turbo Stream append plus a replacement for the sentinel that points at the next page. Stimulus owns the trigger, Turbo owns the rendering. The controller is dumb. The Rails action is the same paginated query you already had.
Optimistic updates with Hotwire feel weird at first because you’re tempted to mutate the DOM yourself. Don’t. Render the optimistic state as a data-turbo-temporary element, post to the server, let the response replace it.
// app/javascript/controllers/like_button_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["count", "icon"]
async toggle(event) {
event.preventDefault()
const prevCount = parseInt(this.countTarget.textContent, 10)
const prevState = this.iconTarget.dataset.liked === "true"
this.applyLocal(!prevState, prevState ? prevCount - 1 : prevCount + 1)
try {
const res = await fetch(this.element.action, {
method: "POST",
headers: {
"Accept": "text/vnd.turbo-stream.html",
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content
}
})
if (!res.ok) throw new Error("server rejected")
Turbo.renderStreamMessage(await res.text())
} catch (_e) {
this.applyLocal(prevState, prevCount)
}
}
applyLocal(liked, count) {
this.iconTarget.dataset.liked = liked
this.countTarget.textContent = count.toString()
}
}
The local mutation is the optimistic state, the server response is truth. If the request fails, snap back to the previous values. If it succeeds, the Turbo Stream replaces the button container with the real count. The temporary state never sticks around; the server always wins eventually.
Two scars worth naming.
The Aurora reader replica thing. That Tuesday at the creator platform, AuroraReplicaLagMaximum crossed 60s. Hotwire happily rendered every post via the reader pool, including posts that had already been replied to on the writer. Users saw a feed that looked fine and was 14 minutes out of date. First wrong fix was scaling reader instance class up two tiers. Did nothing, because the readers weren’t CPU-bound, they were starved of WAL. Real fix was killing a long-running ANALYZE on the writer that was holding locks. Replica lag drained in around six minutes. The Hotwire lesson is, when you render Turbo Streams off the writer and Turbo Frames off readers, the user can see the world be inconsistent with itself. We added a force_writer_read_for_user_actions middleware that pinned the user’s own session reads to the writer for 30 seconds after any write. Kills the “I just posted, where’s my post” class of bug.
WebSocket reconnect storms. Action Cable runs on WebSockets. So does Socket.io, which I shipped on a real-time trading and charting platform I architected, sized for millions of concurrent connections. Tuesday after a long bank holiday, market opens at 09:30, and within 74 seconds the gateway tier was pinned at 100% CPU. Clients dropped, reconnected instantly, dropped again. First wrong fix was scaling the gateway pods 3x. New pods came online, hit the storm, went CPU-bound in 20 seconds. I was feeding the fire. Real fix was a remote-config push for jittered exponential backoff on the client (min: 200ms, max: 30s, factor: 2, jitter: 50%) plus a per-IP connection-rate limiter at nginx. Action Cable inherits this entire failure mode. Whatever you ship Turbo Streams over, the client-side reconnect needs backoff and jitter. Server scaling is not the fix.
Thanks for reading. If you’ve got thoughts, send them my way.