One Renderer: HTML Over the Wire

One Renderer: HTML Over the Wire

The P5 principle tutorial of the Beautiful Rails (Campfire) series. Derives, from first principles and the real Campfire codebase, why you render a message's HTML exactly once on the server and ship that same HTML over every transport — and how the to_key identity convention makes the optimistic client node and the server's broadcast converge, so "the HTTP reply" and "the live update" stop being two features that drift. Three beats: first principles (the naive two-renderer Rails version, derived away), the beauty in combination with P4/P1/P9, and the 37signals evidence at real file:line.

One renderer: one partial paints every transport HTML over the wire — the wire carries HTML, not data The two-renderer drift bug (page render vs broadcast render) to_key as the optimistic-id handshake bridging ActiveModel identity to dom_id The de-dupe is deleted, not written Edit-in-place: broadcast a replace to spectators, redirect the actor, no branch Server declares intent as data on the wire (maintain_scroll), client honors it campfire by nityeshagarwal

The principle: Render HTML on the server once, send that same HTML over every transport, and let identity conventions make the optimistic client node and the server's broadcast converge — so 'the HTTP reply' and 'the live update' are one feature, not two.

① First principles: why you keep accidentally building two renderers

You're building chat, and you want a sent message to do two things: show up instantly on the sender's screen, and show up on everyone else's screen a half-second later, live, without a refresh. You don't know JavaScript, so you reach for the tools you do know — a controller, a partial, ActionCable — and you build the honest first version.

The room's index already renders each message through a partial. Good. So the page-load path is fine:

<%# rooms/show.html.erb — the initial page render %>
<div id="room_<%= @room.id %>_messages">
  <% @messages.each do |message| %>
    <%= render "messages/message", message: message %>
  <% end %>
</div>

Now the live update. A message lands; you need to push it to the open browsers over the socket. ActionCable broadcasts strings, so you render the message to a string and push it:

class MessagesController < ApplicationController
  def create
    @message = @room.messages.create!(message_params)

    # push the live update to everyone's open socket:
    html = render_to_string(partial: "messages/message", locals: { message: @message })
    ActionCable.server.broadcast "room_#{@room.id}", html

    # ...and ALSO respond to the sender's POST so their screen updates:
    render turbo_stream: turbo_stream.append("room_#{@room.id}_messages", @message)
  end
end

Stop and count what you just wrote. You have the message's markup conceptually in three places now — the .each loop on page load, the render_to_string for the socket, and the turbo_stream.append for the HTTP reply — and you've hand-typed the container id "room_#{@room.id}_messages" in the view and again in the broadcast. The day you add a boost badge to the message and update the partial, the page-load path shows it; whether the live path shows it depends on whether all three call sites really resolve to the same partial, and whether the id strings still match. When one drifts, nothing errors — messages just silently stop appearing in the live feed while looking perfect on reload.

A square 1:1 technical poster titled 'THE TWO-RENDERER DRIFT' in a consistent t…
It gets worse, because the sender's screen is special. The sender didn't wait for the round-trip — a good chat app paints the message the instant they hit Enter, optimistically. So now the sender's own message arrives twice: once as the optimistic bubble you drew locally, once as the authoritative broadcast. You patch it:

  # broadcast to everyone EXCEPT the sender, so they don't see it twice...
  @room.memberships.where.not(user: Current.user).each do |m|
    ActionCable.server.broadcast "user_#{m.user_id}_room_#{@room.id}", html
  end
  # ...and now you need per-user channels, and a way for the sender's
  # optimistic bubble to get "confirmed" into the real row, and a guard
  # for when the broadcast races ahead of the POST response.

You've now built: per-user broadcast channels, a temp-id-to-real-id reconciliation pass, and a race guard. None of that is "chat." All of it is bookkeeping to undo a problem you created by treating the live update and the HTTP reply as two separate things that each render their own copy of the markup.

Let's derive the way out from scratch. There are two distinct mistakes tangled together here, and naming them separately is the whole principle.

Mistake one: the wire carries data, so each end must render it. You assumed the socket transports information about the message (a string you rendered, or worse, a JSON blob the client would template) and that "render the message" is therefore a thing that happens at each delivery point. Invert it. What if the wire carried the already-rendered HTML — the exact output of the one partial — and the client's only job was to drop that HTML into the page at a named target? Then there is exactly one renderer: the server's partial. The page load, the HTTP reply to the POST, and the socket push all carry the byte-identical output of messages/_message. There is no second place for the markup to live, so there is nothing to drift. This is HTML over the wire: the wire carries HTML, not data. (How a region of the page knows to swap in incoming HTML at a target id is Turbo's job — the mechanics are see F4: Turbo. Here we only need the worldview: server renders once, ships HTML, client places it.)

Mistake two: the sender's message is "different" so it needs a branch. It isn't different. It's the same message; the only problem is that the sender's optimistic bubble and the server's authoritative append are two DOM nodes that don't know they're the same row. So don't broadcast-except-the-sender and reconcile temp ids — make the two nodes share an identity from the first keystroke. Let the sender's browser choose an id, send it along, and have the server adopt that id as the message's identity. Then when the authoritative HTML arrives carrying that same id, the framework's append-with-an-existing-id semantics replace in place instead of stacking a duplicate. No per-user channels, no reconciliation, no race guard. The de-dupe isn't code you write — it's a consequence of agreeing on identity.

Both fixes are the same move at heart: stop maintaining two copies of something by hand; compute it in one place and let a convention carry it to the other side. One renderer for the markup; one identity for the node. That convergence — the optimistic node and the broadcast becoming the same node — is what this principle is about.

A square 1:1 teaching diagram titled 'THE OPTIMISTIC-ID HANDSHAKE: to_key MAKES…

② The beauty in combination

One-renderer is not a self-contained trick. It only works because three other principles are holding it up, and watching them interlock is where the idea earns its keep.

With P4 (convention is leverage): to_key is the bridge that makes one identity span three layers. The optimistic-node problem reduces to a single question: what is a message's identity? In stock Rails, identity is the database primary key — so dom_id(message) would produce message_472, an id the server only learns after the insert, which the client's optimistic bubble could never have guessed. The fix is to override to_key, the ActiveModel method every identity helper consults, to return a client-chosen value instead of the primary key. Now dom_id speaks the client's UUID, and the optimistic bubble and the authoritative append resolve to the same DOM id for free. That's exactly the convention move from see P4: Convention Is Leverage: when both sides of a boundary ask the framework the same question — here, dom_id, which delegates to to_key — they can never drift. One model method, three layers reconciled (ActiveModel identity → dom_id → Turbo's id-matching). The mechanics of the handshake on the client side live in see F4: the to_key handshake; the worldview — that this is what makes "optimistic" cost nothing — is what we're unpacking here.

With P1 (the model owns its consequences): the broadcast is triggered by persistence, not orchestrated in the controller. One-renderer would be hollow if the controller still had to manually fan the rendered HTML out to every screen. It doesn't, because broadcasting is filed where it belongs. The reason a sent message reaches every screen at all is that persistence declares the consequence — see P1: the model owns the consequence is the framing — and the broadcast reuses the same partial the page used. The two ideas compound: P1 says "saving is the trigger," P5 says "the thing it triggers ships the one rendering everywhere." Notice the deliberate seam here, owned by P1: room.receive is fired from an after_create_commit callback (a consequence of every message existing), but broadcast_create is a plain method called from the controller (a consequence of how this message was made — an interactive send, not a seed import). One-renderer reuses that one broadcast_create across the HTTP reply, the live cable, and even the bot webhook reply — three transports, one rendering, one method.

With P9 (put work at its right altitude): the broadcast is in-band, the push is out-of-band. The instant-paint loop must stay fast, so the rendered-HTML broadcast (cheap, must happen before the response returns) runs in-band, while the slow OS-push fan-out is deferred to a job. That altitude split — see P9: the in-band / out-of-band line — is what keeps the optimistic loop snappy. One-renderer benefits directly: the HTML the sender sees and the HTML everyone else sees both ride the fast path; only the flaky, fan-out-to-the-internet part waits.

Hold all three together and the result is almost absurd: optimistic, real-time, multi-user chat — the feature people pay Slack billions for — is a handful of declarative lines, because each convention absorbs exactly one production edge case. to_key absorbs the duplicate-bubble bug. The shared partial absorbs the two-renderers-drift bug. _commit absorbs the notification-for-a-rolled-back-row bug. The server-tagged broadcast (next beat) absorbs the scroll-yank bug. Count the edge cases this convergence absorbs for free, and you'll find the entire "realtime sync layer" you expected to build is simply not there — each piece was eaten by an identity rule or a shared render.

③ How 37signals did it

Now the evidence, all quoted from the real codebase.

One partial, every transport. The page load renders the list through messages/_message. The HTTP reply to the sender's POST renders the same partial via a one-line turbo_stream template (create.turbo_stream.erb:1):

<%= turbo_stream.append dom_id(@message.room, :messages), @message %>

And the live socket push renders the same partial again, targeting the same dom_id, from a small concern (broadcasts.rb:2-5):

def broadcast_create
  broadcast_append_to room, :messages, target: [ room, :messages ]
  ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end

Both the HTTP append and the websocket append name the identical target — the room's messages container — and both render the identical partial. There is no render_to_string, no JSON serializer, no client-side template, no payload contract. The wire carries the HTML the server already rendered. Change _message and every path changes together, because there is only one path.

And this isn't a Campfire quirk — Fizzy reaches the same destination from a newer Turbo, taking the idea to its limit: instead of naming a target and a partial, a card just declares broadcasts_refreshes (card/broadcastable.rb:5), the board page subscribes with turbo_stream_from @board (boards/show.html.erb:5), and the layout sets turbo_refreshes_with method: :morph, scroll: :preserve (layouts/shared/_head.html.erb:16). The wire payload is now literally the word refresh; every open browser re-renders the same partials and morphs the result into place, with no render_to_string and no bespoke real-time code at all. Two unrelated 37signals products, one principle: the server renders, the wire ships HTML (or an instruction to re-fetch it), the client places it — so this is the house's move, not Campfire's.

"But sending HTML over the wire is wasteful versus a tiny JSON payload, isn't it?" This is the trade DHH made on purpose. You spend a few extra bytes to delete a subsystem: no serializer, no client templating engine, no payload schema kept in sync across two codebases, no second renderer to drift. For a chat message the byte difference is noise; the maintenance difference is an entire layer you never write, test, or debug.

The optimistic-id handshake — the de-dupe that is deleted, not written. The sender's browser draws a placeholder node before the round-trip, with an id of the form id="message_$clientMessageId$" (_template.html.erb:3) — a UUID the client chose. The server adopts that id rather than inventing its own (message.rb:11):

before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care

The ||= only mints one if the client sent none — the dry # Bots don't care is the tell: a bot posting via the API has no optimistic bubble to reconcile, so it doesn't supply an id. And then the three lines that bridge everything (message.rb:27-29):

def to_key
  [ client_message_id ]
end

That is the entire mechanism. to_key is the ActiveModel method dom_id consults, so overriding it makes dom_id(message) resolve to message_<client_message_id> — the same id the sender's optimistic placeholder already carries. When broadcast_append_to ships the authoritative HTML, Turbo sees a node with an id that already exists in the DOM and replaces it in place instead of appending a duplicate. There is no reconciliation pass, no temp-id-to-real-id swap, no per-user "broadcast except creator" channel, no race guard. The duplicate-bubble bug doesn't get fixed — it never gets a chance to exist, because the client and server agreed on identity from the first keystroke.

Fizzy proves the same instinct with a different mechanism: when you drag a card to a new column, the browser moves the node optimistically, and the server's reply is a morph — turbo_stream.replace(dom_id(@column), partial: "boards/show/column", method: :morph, ...) (columns/cards/drops/columns/create.turbo_stream.erb:1). Morph reconciles the authoritative HTML against the node already on screen instead of stacking a duplicate, so the optimistic move and the server's truth converge for free — Campfire's to_key handshake and Fizzy's morph-reconciled drag are the same shape (optimistic realtime as a handful of declarative lines), tuned to two different surfaces. (see P4: Convention Is Leverage for why two sides asking dom_id the same question can't drift; see C1: The Line Where Saving Becomes a Product traces this convergence end-to-end across the whole send arc.)

Edit-in-place: broadcast a replace to spectators, redirect the actor — no branch. The same one-renderer worldview handles editing, and this is where the absence of a conditional is most striking. Three files agree on one frame id, dom_id(message, :edit) — the message partial wraps its body in <turbo-frame id="<%= dom_id(message, :edit) %>"> (_message.html.erb:11), the Edit link targets that frame (_actions.html.erb:52), and the edit view renders into it. So when you click Edit, the message becomes an editor in place, and the controller's def edit; end is empty — the matching frame ids do the swap with zero controller code. The update action (messages_controller.rb:36-41) is where the elegance peaks:

def update
  @message.update!(message_params)

  @message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
  redirect_to room_message_url(@room, @message)
end

One action serves two audiences with no if current_user == ... branch. Everyone watching the room gets a broadcast replace of the message's presentation node — the same one-renderer machinery, a different verb. The person who did the editing gets a plain redirect, because their submit happened inside the edit frame, so the redirect's response naturally swaps that frame back to the presentation. Two outcomes, one method, zero conditionals — the branch you'd expect simply isn't there, because each audience's path is determined by where their request originated, not by a runtime check.

Fizzy edits a card the same way, only the HTTP reply and the live update have fully merged into one response: its update action is four lines (cards_controller.rb:35-43) that just update! and respond_to, and the matching cards/update.turbo_stream.erb:2 replaces the card's container with the same partials the first paint used — turbo_stream.replace dom_id(@card, :card_container), partial: container_partial, method: :morph, .... The HTTP reply is the live update, morph instead of replace; the editor's submit and every spectator's repaint route through one rendering. Same one-renderer worldview, witnessed twice.

The server declares intent as data; the client honors it. Look at that attributes: { maintain_scroll: true } on the broadcast. The model and the controller never learn that a UI exists, never touch scroll position — but they need a way to tell the client "this is an edit, don't yank the scroll to the bottom." So the server stamps that intent as a plain attribute on the wire, and the client-side glue reads it and behaves accordingly. The Stimulus code that honors it is see F4: the maintain_scroll seam and is explicitly background — you don't need to read a line of JavaScript. The Rails-side lesson is the whole point: declare intent as data on the broadcast, and let the client interpret it, rather than encoding behavior into two diverging renderers. The transport stays generic (it's still just HTML at a target); the nuance rides along as a labeled attribute.

One rendering even survives the laptop closing. When a client wakes from sleep, the catch-up isn't a full reload — it's a diff that reuses the very same partial and the same stream verbs (rooms/refreshes/show.turbo_stream.erb):

<%= 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 append, edited ones replace by their dom_id — the same two verbs, the same one partial, a fifth delivery path that still cannot diverge from the other four. That's the proof the principle is load-bearing and not a one-off: every way a message can reach a screen — first load, the sender's HTTP reply, the live cable append, an edit's replace, a wake-from-sleep refresh — routes through the one messages/_message partial and addresses the node by dom_id. Five paths, one renderer, zero glue between them.

A square 1:1 technical 'fan' diagram titled 'ONE RENDERER, FIVE PATHS' in a con…
This is the throughline in its P5 costume: the whole real-time layer is small not because it's clever, but because each transport trusts the same convention at its boundary — one partial for the markup, one to_key for the identity — so the framework quietly absorbs the drift, the duplication, and the reconciliation you'd otherwise hand-write.

Key Takeaways — Patterns to Steal

  • When the same row has to show up on page load, in the POST reply, and over the live socket, render it in exactly one place — the server partial — and let every transport ship that byte-identical HTML to the same target id. The tempting first draft renders three copies: a .each loop in the view, a render_to_string for the cable, and a separate turbo_stream.append for the reply, and the day they drift nothing errors — messages just silently stop appearing live while looking perfect on reload. Campfire's messages/_message is the only renderer: create.turbo_stream.erb:1 appends it for the HTTP reply and broadcasts.rb:3 appends the very same partial to the same dom_id for the socket, so changing the partial changes every path at once.
  • Put rendered HTML on the wire, not data — the client's only job is to drop the server's output into the page at a named id. Don't reach for a JSON blob the browser will template, because that buys you a serializer, a client-side rendering engine, and a payload schema you now have to keep in sync across two codebases. broadcasts.rb:2-5 carries plain rendered HTML and nothing else; the few extra bytes versus a tiny JSON payload are noise, and what you bought with them is an entire layer you never write, test, or debug.
  • Make the sender's optimistic bubble and the server's authoritative append the same DOM node by overriding to_key so dom_id speaks a client-chosen id instead of the primary key. The naive path — broadcast-to-everyone-except-the-sender, then reconcile a temp id into the real id with a race guard — is all bookkeeping to undo a duplicate you created by letting the server invent the identity after the insert. Campfire's message.rb:27-29 is just def to_key; [ client_message_id ]; end, so dom_id(message) resolves to the id the placeholder already carries, Turbo replaces in place, and the duplicate-bubble bug never gets a chance to exist.
  • Split the consequence by what triggered it: a thing true of every message belongs on a persistence callback, while a thing true only of an interactive send belongs in a plain method the controller calls. Don't cram the interactive broadcast into the same after_create_commit that fires for a seed import — those aren't the same event. Campfire fires room.receive(self) from after_create_commit in message.rb:12 (every message exists), but broadcast_create in broadcasts.rb:2 is an ordinary method invoked from the controller (this message was sent by a human), and the _commit suffix is the promise that you won't notify anyone about a row that just rolled back.
  • When a feature needs the same id in three files, name it once with a dom_id variant and let the framework's id-matching do the wiring. The naive version routes editing through a full-page detour and a controller that fetches and re-renders; instead, wrap the body in a turbo-frame and point the link at that frame's id. Campfire uses dom_id(message, :edit) in _message.html.erb:11 and again in the Edit link's turbo_frame in _actions.html.erb:52, so clicking Edit turns the message into an editor in place and def edit; end in the controller is genuinely empty.
  • When the client needs to behave differently for one kind of update, stamp that intent as a plain attribute on the broadcast and let the client read it — keep the model and controller ignorant that a UI even exists. Don't fork into a second, scroll-aware renderer to handle the edit case; that's two renderers drifting again, just wearing a behavior costume. The update broadcast in messages_controller.rb:39 carries attributes: { maintain_scroll: true }, so the transport stays generic HTML-at-a-target and the "don't yank the scroll to the bottom" nuance rides along as a labeled flag the client honors — no Rails-side coupling and no JavaScript for you to write.
  • Route every way a message can reach a screen through the one partial addressed by dom_id, so no path can be the odd one out. The naive catch-up after a laptop wakes is a full reload; instead, send a diff built from the same partial and the same two stream verbs. Campfire's rooms/refreshes/show.turbo_stream.erb appends new messages and replaces edited ones by their dom_id, making it the fifth delivery path — first load, HTTP reply, live cable append, edit replace, wake-from-sleep refresh — that all funnel through messages/_message, which is exactly why the "it only works after I refresh" class of bug has nowhere to live.