Turbo: Frames & Streams
A Foundations tutorial on Turbo (Hotwire) in Campfire, taught from first principles for a JS-naive Rails builder. Derives Turbo Drive, Frames, and Streams, then shows the keystone mechanic: a Turbo Stream over HTTP and a model broadcast over WebSocket target the identical dom_id and reuse the identical partial, so the HTTP reply and the live update are literally the same HTML. Covers the optimistic-id to_key handshake, broadcasting as a deliberate explicit method (not a callback), edit-in-place replace, and the wake-from-sleep refresh diff — all grounded in real Campfire file:line. Points to P5 (One Renderer) and P4 (Convention Is Leverage).
You're building chat. A message has to appear on the sender's screen the instant they hit Enter, and on four other people's screens a half-second later — and it has to keep appearing after an edit, after a delete, and after a laptop wakes from sleep two hours behind. That's at least five different moments where the same message lands in the same spot in the DOM. The question this whole tutorial answers: how many of those five moments can you make literally the same code?
Let's be honest about the version you'd vibe-code first — the one you'd write before the conventions clicked. You render the message one way for the page, and then, because the live update is "a different thing," you render it again by hand and shove it down the socket:
class MessagesController < ApplicationController
def create
@message = @room.messages.create!(message_params)
# render the message to an HTML string, by hand...
html = ApplicationController.render(
partial: "messages/message", locals: { message: @message }
)
# ...push that string over the socket yourself...
ActionCable.server.broadcast "room_#{@room.id}", { html: html, id: @message.id }
# ...and ALSO render the same markup into the HTTP response, a second way:
render turbo_stream: turbo_stream.append(
"room_#{@room.id}_messages",
"<div id='msg-#{@message.id}'>#{@message.body}</div>" # a third spelling of the markup
)
end
end
Three problems are already baked in, and none of them will throw an error — they'll just silently drift. The page renders the message one way (_message partial), the socket carries a second hand-rendered copy, and the HTTP response inlines a third. The container id is hand-typed ("room_#{id}_messages") in one place and the row id ("msg-#{id}") in another — and somewhere else the page wrote "message_#{id}". The day you add a boost badge to the partial, the page shows it and the broadcast forgets it. And the sender, who already drew their own message optimistically, now sees it twice — so you reach for window.onfocus = () => location.reload() to "fix" catch-up and a hand-rolled de-dupe pass to "fix" the double. You are maintaining the same feature in three spellings.
Turbo's entire premise is that you should never have written the second and third copies. Let's derive why.
The mechanics, from first principles
Turbo is a server-driven UI model: the server keeps rendering HTML, and the browser keeps swapping pieces of the page without you writing a single line of fetch-and-render glue. It comes in three escalating mechanics. (Stimulus — the JavaScript glue — is acknowledged here and kept deliberately in the background; the only thing we study is the Rails seam.)
Turbo Drive is the floor. It intercepts a plain full-page navigation — a link_to, a regular form submit — turns it into a background fetch, and swaps the new <body> into the current page instead of doing a hard reload. You wrote a boring GET /rooms/5; the reader experiences an SPA-like transition. You did nothing. This is why opening a room in Campfire is just a GET to #show — there is no "switch room" code, because Drive makes a normal navigation feel instant see F2: Controllers & Routing.
A Turbo Frame is a region of the page that updates on its own. Wrap a chunk of the page in a frame with an id; any link or form whose response contains a frame with that same id replaces only that region, leaving the rest of the page untouched. Add src: and the frame lazy-loads its content in a second request; add turbo_permanent: true and it survives navigation entirely. That is exactly how Campfire's sidebar works — one frame, loaded once, persisted across every room switch (users/sidebar_helper.rb:2-9):
def sidebar_turbo_frame_tag(src: nil, &)
turbo_frame_tag :user_sidebar, src: src, target: "_top", data: {
turbo_permanent: true,
# ...
}, &
end
### Lazy frames as the default, eager on intent
Campfire uses a frame exactly once — the sidebar above — loaded once and persisted. That's the conservative read of the feature: a frame is for the *one* region that outlives navigation. Fizzy takes the same mechanic and makes it the **page-assembly strategy.** A Fizzy board doesn't render its columns' cards in the initial response at all; each column ships an *empty* frame that names its own content endpoint and fills itself in a second request (`boards/show/_column.html.erb:18`):
```erb
<%= column_frame_tag dom_id(column, :cards), src: board_column_path(column.board, column) %>
That is the whole Rails-visible contract: a frame with an id and a src:, and a perfectly ordinary resourceful GET (#show) at the other end that renders the cards. The first response paints the board's skeleton instantly; each column's heavy card list arrives on its own request, in parallel, without you writing a single line of fetch-and-render glue. The same shape recurs for the assignee picker inside a card — another empty-src frame pointed at a new_… endpoint (cards/display/perma/_assignees.html.erb:2):
<%= turbo_frame_tag card, :assignment, src: new_card_assignment_path(card), loading: :lazy, refresh: "morph" %>
A wall of these — one per column, one per deferrable widget — is the default, not a one-off. The payoff is the rich-interactivity-without-a-second-app line again: this is lazy loading, parallel fetching, and per-region refresh, and every piece of it is a turbo_frame_tag plus a normal Rails route. You never stood up a client that knows how to request-and-stitch; the frame is the client.
The eager-on-intent half is also a Rails contract, not a JavaScript lesson. A lazy frame normally waits until it scrolls into view; Fizzy prefetches on intent — hovering the menu, opening a dialog — by flipping the frame's loading attribute to eager so the request fires before the click lands. The Stimulus that does it is one line and stays background (dialog_controller.js:67-69):
loadLazyFrames() {
Array.from(this.dialogTarget.querySelectorAll("turbo-frame")).forEach(frame => { frame.loading = "eager" })
}
wired to a plain mouseenter in the markup (my/_menu.html.erb:3). The thing to study isn't the JS — it's that the server side is built to make that prefetch cheap and safe to repeat. The menu frame's endpoint computes a strong ETag from exactly the records it renders (my/menus_controller.rb:9):
fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ]
So the hover-triggered prefetch, and the real navigation a half-second later, hit the same URL — and the second one comes back 304 Not Modified against the browser cache. The frame's src: declares where the content lives; fresh_when declares when it's allowed to be reused. Prefetch-on-intent costs almost nothing because the endpoint is conditional-GET-aware, and that part is pure Rails (my/_menu.html.erb:19 is just a plain lazy turbo_frame_tag pointed at my_menu_path).
The contrast: the naive way to make a board feel fast is to render every column's full card list in the first response and eat a slow initial paint, then reach for window.onfocus = () => location.reload() to refresh a stale region. Fizzy ships an empty frame per region (instant skeleton), lets each fill itself over a normal route, prefetches the ones the user is about to need by flipping one attribute, and makes that prefetch free with a fresh_when ETag — interactivity you'd otherwise build a second app for, assembled from turbo_frame_tag, a resourceful GET, and one conditional-GET line.
```
A Turbo Stream is the keystone, and it's the one to slow down on. A stream is a fragment of HTML wrapped in an action (append, prepend, replace, remove) aimed at a target DOM id. It says, literally: "take this HTML, and append it to the element with this id." The crucial thing — the thing the naive version above misses — is that a stream is delivered over two transports through the exact same mechanism:
- As the HTTP response to a form submit (a
create.turbo_stream.erbtemplate), and - Pushed over a WebSocket from the model via
broadcast_append_to.
Same action, same target, same HTML — one is in-band (the reply to the request) and one is out-of-band (a push to everyone else). This is the load-bearing idea of the whole tutorial: HTML over the wire — the wire carries HTML, not data. There is no JSON payload schema, no serializer, no event contract to keep in sync between a server and a client. The server already knows how to turn a message into HTML — it has a partial — so it sends that.
"Isn't shipping HTML over a socket wasteful compared to a tiny JSON blob?" This is the trade DHH made on purpose. You spend a few extra bytes to delete an entire subsystem: no client-side templating engine, no serializer, no payload contract, no second renderer, no divergence bug. For a chat message the byte difference is noise; the maintenance difference is a whole layer you never build. We don't unpack the full worldview here — that's see P5: One Renderer's job. Here you only need the mechanic: a stream is HTML + an action + a target id, over either transport.
The 37signals way
One partial, addressed by one function, over both transports
Here is the entire send fan-out in Campfire. The controller's create does not render anything by hand (messages_controller.rb:20-28):
def create
set_room
@message = @room.messages.create_with_attachment!(message_params)
@message.broadcast_create
deliver_webhooks_to_bots
rescue ActiveRecord::RecordNotFound
render action: :room_not_found
end
The HTTP response is a one-line template (create.turbo_stream.erb:1):
<%= turbo_stream.append dom_id(@message.room, :messages), @message %>
The WebSocket broadcast is two lines in a concern (broadcasts.rb:2-4):
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end
def broadcast_remove
broadcast_remove_to room, :messages
end
end
Look at what is identical across both. The target on the HTTP side is dom_id(@message.room, :messages). The target on the WebSocket side is [room, :messages] — which Turbo resolves through dom_id to the byte-identical string. And both render the message through the same partial: broadcast_append_to defaults to messages/_message, which is the same partial the initial page load uses (rooms/show.html.erb:17):
<%= render partial: "messages/message", collection: @messages, cached: true %>
So the container id is computed, never typed. The page renders the list into a container whose id is dom_id(@room, :messages) (messages_helper.rb:16), each row gets dom_id(message) (messages_helper.rb:28), and the page subscribes to the literal same channel the model broadcasts to (rooms/show.html.erb:20):
<%= turbo_stream_from @room, :messages %>
That last line is the entire real-time wiring.
No socket handler, no event emitter, no channel class to write. The view subscribes to
[@room, :messages]; the model broadcasts to [room, :messages]; dom_id guarantees both sides spell the channel and the target the same way because both ask the same function. That convention — both sides of a boundary asking the framework the same question so they can't drift — is the deep idea, and it has a home: see P4: Convention Is Leverage.
The contrast: the naive version owned three renderings of the message (page, socket, HTTP reply) and two hand-typed id strings (container, row), all free to drift the moment you touch the partial. Campfire owns one partial and zero id strings — every id is dom_id(model). Add a boost badge to _message and it appears identically on first load, on the live append, and on the catch-up refresh, because there is exactly one render path.
The optimistic-id handshake: the de-dupe is deleted, not written
There's still the double-message bug to kill: the sender drew their own message optimistically the instant they hit Enter, and now the authoritative copy arrives over the wire. Naively, those are two different nodes with two different ids, so the sender sees the message twice and you write a reconciliation pass — track a temporary id, find the placeholder, swap it, and guard the race where the broadcast beats the response.
Campfire writes none of that. Watch four lines do it. The sender's composer (JavaScript — background only) draws a placeholder node with an id it chose: id="message_$clientMessageId$" (_template.html.erb:3), a client-generated UUID. That UUID rides along in the form as client_message_id. The server persists it, defaulting one only if absent (message.rb:11):
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
And then the hinge — the model overrides what identity means (message.rb:27-29):
def to_key
[ client_message_id ]
end
to_key is the ActiveModel method every Rails identity helper consults. By returning the client_message_id instead of the database primary key, dom_id(@message) no longer yields message_472 — it yields message_<the-uuid-the-client-already-chose>. So when broadcast_append_to renders the authoritative message, it carries the same id the optimistic placeholder already has in the DOM. Turbo's append-with-an-existing-id semantics replace in place rather than stacking a duplicate. One model method bridges three layers — ActiveModel identity → dom_id → Turbo's id matching — and the entire reconciliation pass evaporates.
"So
to_keyis just for the DOM?" No —to_keyis core ActiveModel identity, anddom_idis one of its consumers. We're only using the mechanics here; the full worldview of why the optimistic node and the broadcast are designed to converge belongs to see P5: the optimistic-id handshake. For F4, hold onto the mechanic: overrideto_key, and the client-chosen id becomes the row's address everywhere.
The contrast: the naive version draws a placeholder with a local temp id, then hand-writes "find the temp node, swap in the real db id, and don't double-render if the broadcast wins the race." That reconciliation is a documented, classic source of duplicate-and-flicker bugs. Here the de-dupe is deleted, not written — it falls out of one overridden method and Turbo's replace-on-existing-id rule.
Broadcasting is a deliberate method, not a callback
Notice what broadcast_create is not: it is not an after_create_commit callback. It's a plain method in a plain module (broadcasts.rb:1, no included do), called explicitly at each call site — messages_controller.rb:24 and webhook.rb:60. That's a choice, and it's the right one.
A live broadcast is a property of how a message came into being, not of the message existing. A seed, an import, or a rake task creates messages too, and none of them should fire a browser broadcast. And create/update/destroy need three different broadcasts (append, replace, remove) — a single after_create_commit :broadcast could only ever express one. So broadcasting stays an explicit verb at the call path. Contrast its neighbor one line down, after_create_commit -> { room.receive(self) } (message.rb:12), which is a callback precisely because marking people unread is true for every message that exists. Knowing which effect belongs to the record (callback) and which belongs to the call path (explicit method) is the whole craft — and that distinction's deep home is see P1: The Model Owns Its Consequences, while the _commit lifecycle itself lives in see F1: the callback lifecycle.
Edit and delete: the same partial, a different verb
Because the message is addressed by dom_id, editing is just a replace aimed at the same node, and deleting is a remove.
Fizzy edits a card the identical way — its update template is turbo_stream.replace dom_id(@card, :card_container), partial: ..., method: :morph (cards/update.turbo_stream.erb:2): the node addressed by dom_id, the same partial that drew it, a different verb. Two products, same edit shape. The update action broadcasts a replace to spectators and redirects the actor — no if creator == current_user branch (messages_controller.rb:39):
@message.broadcast_replace_to @room, :messages,
target: [ @message, :presentation ],
partial: "messages/presentation",
attributes: { maintain_scroll: true }
That attributes: { maintain_scroll: true } is the one Rails-side Stimulus seam worth naming: the server stamps intent as data on the wire, and the client honors it. The model never learns a UI exists; it just declares "this change should maintain scroll" as an attribute on the broadcast. The JavaScript that reads it is background — the beautiful, learnable half is that the instruction travels as data, not as code see P5: HTML over the wire.
Uploads: one attachment line, one polymorphic blob partial
File uploads feel like they should break the one-partial discipline — an image, a video, a PDF, and a random binary each look different, so surely the code branches per type somewhere. It doesn't. The same "the server already knows how to render this thing" logic that powers streams powers uploads too, and Fizzy is the clean second witness because Campfire's attachment story is tangled into its message composer.
It starts with one declarative line on the model. A card simply has an attached image (card.rb:11):
has_one_attached :image, dependent: :purge_later
That one line gives you the blob, the storage indirection, the variants, and the URLs — no upload controller, no file-handling code, no MIME bookkeeping in the model. The whole "how do I store and serve a file" subsystem is a convention you opt into, not a layer you write.
The rendering side is where the polymorphism lives, and it's the same move as _message: one partial, which asks the blob what it is rather than the controller branching on type (active_storage/blobs/web/_representation.html.erb:5-25):
<% if blob.video? %>
<%= tag.video src: rails_blob_path(blob), controls: true, preload: :none, ... %>
<% elsif blob.audio? %>
<audio controls="true" width="100%" preload="metadata">
<source src="<%= rails_blob_path(blob) %>" type="<%= blob.content_type %>">
</audio>
<% elsif blob.variable? %>
<%= link_to rails_representation_path(blob.variant(variant)), ... do %>
<%= image_tag rails_representation_path(blob.variant(variant)), ... %>
<% end %>
<% elsif blob.previewable? %>
<%= image_tag rails_representation_path(blob.preview(variant)), ... %>
<% else %>
<span class="attachment__icon"><%= blob.filename.extension&.downcase.presence || "unknown" %></span>
<% end %>
The questions — blob.video?, blob.previewable?, blob.variable? — are ActiveStorage's, not Fizzy's. The partial doesn't know an mp4 from a PDF; it asks the blob, and the blob answers from its content type. And because this is active_storage/blobs/web/_representation, it lives at the conventional override path: Rails ships a default blob partial, and Fizzy supplies its own at that exact name to render attachments its way — the same override-by-naming convention that lets you replace any framework partial without touching the framework.
Two server-side beats are worth a sentence each. First, Fizzy processes its image variants immediately on attach rather than lazily on first view (action_text.rb:9-11, configuring the variants declared at attachments.rb:4-6):
attachable.variant variant_name, **variant_options, process: :immediately
The comment says exactly why: "Processed immediately on attachment to avoid read replica issues (lazy variants would attempt writes on read replicas)." (attachments.rb:4-6). A lazily-generated variant writes the resized file the first time someone reads the page — and if that read is served by a read replica, the write fails. Processing on attach moves the write to the moment a write is already happening. That's a production edge case absorbed by one option, exactly the kind this series keeps counting.
"So is there a JavaScript uploader I'm not seeing?" There's a tiny one —
upload_preview_controller.jsswaps in a localURL.createObjectURL(file)preview the instant you pick a file (upload_preview_controller.js:6-11), so you see the image before it finishes uploading. It's the kind of small optimistic-UI preview controller that's a settled 37signals house pattern, not a per-app flourish. But it's pure optimistic UI; the Rails seam — onehas_one_attached, one blob partial asking the blob what it is — is the whole lesson.
Wake-from-sleep is a diff, not a reload
The naive catch-up was window.onfocus = () => location.reload() — throw away the whole page and refetch. Campfire instead asks "what changed since I last saw this room?" and answers with a stream that reuses the same verbs and the same partial (rooms/refreshes/show.turbo_stream.erb:1-7):
Fizzy reaches the same "diff, don't reload" end by a newer mechanism: it declares turbo_refreshes_with method: :morph once in the head (shared/_head.html.erb:16), so a broadcasts_refreshes push re-renders the page and morphs only the nodes that actually changed — the catch-up is still a diff, the wire still carries HTML, just reconciled by morph instead of explicit verbs.
<%= turbo_stream.append dom_id(@room, :messages) do %>
<%= render partial: "messages/message", collection: @new_messages, cached: true %>
<% end if @new_messages.any? %>
<% @updated_messages.each do |message| %>
<%= turbo_stream.replace dom_id(message), partial: "messages/message", locals: { message: message } %>
<% end %>
New messages are appended by the container's dom_id; edited messages are replaced by each message's dom_id. Same partial, same dom_id, same stream verbs you already met — just pointed at a diff the controller computed (refreshes_controller.rb:7-8), with .without(@new_messages) making sure a brand-new message isn't counted as both new and updated. A wake-from-sleep catch-up is the send arc and the edit arc, replayed in bulk, over the identical machinery.
The whole journey, on one screen
Five moments — first load, the sender's optimistic draw, the HTTP reply, the live broadcast, and edit/refresh — all converge on one partial and one dom_id. That convergence is the payoff: real-time chat, the thing people pay Slack billions for, assembled from a one-line template, a two-line broadcast, and one overridden identity method, because each layer trusts the convention at its boundary instead of hand-rolling glue across it.
Which principles this serves
F4 is the transport half of the codebase's highest-conviction idea. The mechanics live here; the worldview lives in two principles:
- see P5: One Renderer: HTML Over the Wire — why "the HTTP reply" and "the live update" are one feature, not two; the full unpack of the
to_keyhandshake and server-tagged broadcast intent. - see P4: Convention Is Leverage — why both sides asking
dom_idandto_keythe same question is what makes drift impossible; the convention as the glue P5 needs.
And the send arc you traced in mechanics here is re-derived end-to-end, with every principle interlocking, in the published capstone see C1: The Line Where Saving Becomes a Product.
Key Takeaways — Patterns to Steal
- The moment a feature has to show the same thing in more than one place — the page, the live update, the catch-up after a reconnect — the temptation is to render it once for the page and then hand-render it again for each other path, which is how you end up with the same message spelled three ways and a boost badge that shows up on first load but goes missing on the broadcast. Render one partial and point every path at it instead. Campfire's initial page (
rooms/show.html.erb:17), HTTP reply (create.turbo_stream.erb:1), and WebSocket broadcast (broadcasts.rb:3) all rendermessages/_message, so there is exactly one version and nothing to keep in sync. - Never type a DOM id as a string — not
"room_#{id}_messages"for the container, not"msg-#{id}"for the row — because the day the page spells it one way and the broadcast spells it another, they drift with no error to warn you. Address every node by askingdom_id(model)on both sides of the wire. Campfire computes the container asdom_id(room, :messages)(messages_helper.rb:16) and each row asdom_id(message)(messages_helper.rb:28); rename the resource and both transports update together because both asked the same function. - When you want a live update, the reflex is to write a socket handler, an event name, and a channel class, then hope the view and the model agree on what to call them. Don't author that contract — have both sides ask the framework the same question. The view subscribes with
turbo_stream_from @room, :messages(rooms/show.html.erb:20) and the model pushes withbroadcast_append_to room, :messages(broadcasts.rb:3);dom_idguarantees both spell the channel identically, so the entire real-time wiring is that one subscribe line. - The sender draws their own message optimistically the instant they hit Enter, and then the authoritative copy arrives over the socket — two nodes, two ids, one visible duplicate. The naive fix is a reconciliation pass: track a temp id, find the placeholder, swap in the real db id, and guard the race where the broadcast beats the response. Campfire deletes that instead of writing it by overriding
to_keyto return[ client_message_id ](message.rb:27-29), sodom_idyields the client's own UUID and Turbo's append-on-existing-id rule replaces the placeholder in place. - When an effect reaches out to browsers — a broadcast — resist wiring it as an
after_create_commit :broadcastcallback, because then a seed, an import, or a rake task fires a ghost broadcast to nobody, and create/update/destroy each need a different verb (append, replace, remove) that one callback can't express. A live broadcast is a property of how the message was sent, not of it existing, so keep it an explicit method called at the call path. Campfire'sbroadcast_createis a plain module method (broadcasts.rb:2) invoked atmessages_controller.rb:24— noskip_broadcastflag anywhere. - Editing and deleting feel like they need their own rendering logic and a "did the right person do this?" guard, but because the message is already addressed by
dom_id, an edit is just areplaceaimed at the message's node and a delete is aremove. Campfire broadcastsbroadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation"on update (messages_controller.rb:39) andbroadcast_removeon destroy (messages_controller.rb:45, defined atbroadcasts.rb:7-9) — a different verb keyed ondom_id, with noif creator == current_userbranch, so spectators never sit on a stale message until they refresh. - After a laptop wakes hours behind, the lazy fix is
window.onfocus = () => location.reload()— throw away the whole page and refetch everything. Instead, ask what changed and answer with the verbs you already have: append the new, replace the edited, each bydom_id, through the samemessages/_messagepartial. Campfire computes the diff inrefreshes_controller.rb:7-8and renders it inrooms/refreshes/show.turbo_stream.erb:1-7, using.without(@new_messages)so a brand-new message isn't counted as both new and updated — the send and edit arcs replayed in bulk over identical machinery.