The Line Where Saving Becomes a Product: One Message, Every Screen, Zero Glue
A first-principles walk through Campfire's send-a-message flow, tracing a single message from the sender hitting Enter to its identical appearance on every connected screen. We follow the arc in order through five verified seams — the after_create_commit trigger, the sync/async fan-out, the one-partial broadcast, the to_key override that deletes an entire de-dupe bug class, and the decoupled back-half — showing at each stop that the MODEL, not the controller, owns the real-time experience. Every claim is grounded in quoted Campfire code, and each section contrasts the elegant version against the naive Rails code you'd vibe-code first, so the elegance lands by comparison.
Cold open: the version you'd naturally vibe-code first
You're building chat. Real chat — the kind where you type, hit Enter, and your message shows up on five other people's screens before you've let go of the key. Let's be honest about the first version you'd actually write — the one you'd vibe-code before you understood why DHH does it differently.
You start in the controller, because that's where the request lands and that feels like where the work goes. You insert the row. Fine. But inserting isn't the feature — appearing everywhere is the feature. So right there in create, after the save, you start orchestrating by hand: mark everyone else's membership unread, then loop over the room's members and send each one a push notification, then broadcast the new message out to the live screens. The controller grows into a to-do list of consequences:
class MessagesController < ApplicationController
def create
@message = @room.messages.create!(message_params)
# ...and now the controller becomes a to-do list of consequences:
@room.memberships.where.not(user: Current.user).each do |membership|
membership.update!(unread_at: Time.current) # mark unread, one by one
PushNotifier.deliver(membership.user, @message) # ...and block on each push
end
ActionCable.server.broadcast "room_#{@room.id}", render_message_html(@message)
end
end
Then you reach for an after_create callback on the model to mark things unread — feels cleaner than stuffing it in the controller — but you wire it as a plain after_create, which fires inside the database transaction:
class Message < ApplicationRecord
include Attachment, Broadcasts, Mentionee, Pagination, Searchable
belongs_to :room, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :boosts, dependent: :destroy
has_rich_text :body
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
after_create_commit -> { room.receive(self) }
scope :ordered, -> { order(:created_at) }
scope :with_creator, -> { preload(creator: :avatar_attachment) }
scope :with_attachment_details, -> {
with_rich_text_body_and_embeds
with_attached_attachment
.includes(attachment_blob: :variant_records)
}
scope :with_boosts, -> { includes(boosts: :booster) }
def plain_text_body
body.to_plain_text.presence || attachment&.filename&.to_s || ""
end
def to_key
[ client_message_id ]
end
def content_type
case
when attachment? then "attachment"
when sound.present? then "sound"
else "text"
end.inquiry
end
def sound
plain_text_body.match(/\A\/play (?<name>\w+)\z/) do |match|
Sound.find_by_name match[:name]
end
end
end
One day the transaction rolls back after that callback ran, and now you've notified people about a message that no longer exists: a push to a phone for a ghost row.
Then you realize the sender's request is slow — it's sitting there blocking while your loop sends a push to every single member one at a time, and one flaky notification call drags the whole send to a crawl. So you start thinking about background jobs, but you've tangled the fan-out into the request so tightly it's hard to pull apart.
Then you render the message one way for the initial page load and a different way for the live update — two chunks of view code that were "the same" the day you wrote them and drift apart by the next feature. And the sender sees their own message twice — once from the optimistic node you drew instantly, once from the live update — so you hand-write a reconciliation pass that tracks a temporary id and swaps it for the real database id when the broadcast arrives, plus a guard for when the two race. Then the page jumps when you're scrolled up reading old messages, so the scroll logic, the sound, and the de-dupe all pile into the one place that also renders the message.
THE NAIVE RAILS "SEND A MESSAGE" (the version you'd vibe-code first)
[Enter]
|
v
composer ──► MessagesController#create
| |
| ├──► Message.create
| ├──► mark every other member unread (by hand)
| ├──► loop members → send_push one-by-one ◄── request BLOCKS here
| └──► broadcast the new message ◄── rendered one way...
|
| (optimistic node drawn instantly on the sender's screen)
v
live update arrives ──► rendered a SECOND way ◄── ...drifts from the page version
|
└──► reconcile(tempId → realId) + guard the race ◄── the de-dupe nobody wanted to write
|
if (near bottom) scrollToBottom(); playSound()
↑ scroll + sound + dedupe + render all tangled in one place
A fat controller orchestrating every consequence by hand. Two renderings of the same message that inevitably diverge. A pre-commit callback that fires for rows that roll back. A reconciliation pass that is a classic source of duplicate-message and flicker bugs. This isn't a strawman — it's the honest shape of the first thing you'd build before the conventions clicked.
Now watch Campfire do the whole thing with one durable callback, one reused partial, and one overridden identity method. The thesis of this whole tour is a single sentence: in Rails, the model owns the consequence. Persisting the row is the broadcast trigger. There's no orchestration in the controller, no second renderer, and — via one override — not even a de-dupe to write. Real-time isn't a feature you bolt on. It's what falls out when persistence, identity, and rendering each live in exactly the right place.
Let's trace one message across the wire, stopping at each verified seam.
Step 1 — The Trigger: the model declares its own consequence
Open app/models/message.rb. Before we find the hinge, notice the shape of the file. The very first thing after the class declaration (message.rb:1-2):
class Message < ApplicationRecord
include Attachment, Broadcasts, Mentionee, Pagination, Searchable
That one line is the spec. A message is attachable, broadcastable, mentionable, paginatable, searchable. The whole class is one screen — a 44-line table of contents, not an implementation. Each concern lives at app/models/message/<name>.rb, so the folder structure mirrors the include list. (User does the same: include Avatar, Bannable, Bot, Mentionable, Role, Transferable.) The included do block lets a concern wire its own callbacks against the host — Message::Searchable registers three SQLite FTS callbacks and a search scope, and Message never has to know they exist:
module Message::Searchable
extend ActiveSupport::Concern
included do
after_create_commit :create_in_index
...
"Wait, isn't spreading behavior across six files just hiding complexity?" No — it's placing it. The naive instinct is one giant
Messagemodel with every behavior crammed in — search indexing, attachments, mentions, pagination — until it's a 600-line file nobody can read, plus aMessageServiceyou peel off when the model gets scary, withupdate_search_index(message)calls sprinkled into every create/update/delete path. Full-text logic smeared everywhere; nobody can open one file and see what a message can do. Here,message.rbreads in 20 seconds and you drill into exactly the one concern you care about.
Now line 11-12 — the hinge of this entire tutorial:
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
after_create_commit -> { room.receive(self) }
Read line 12 out loud: after the create commits, the room receives this message. That's the whole fan-out. One line.
Two things make it beautiful. First, the verb: a room receives a message. The callback speaks the domain, not the framework. Second, that _commit suffix is load-bearing — it's not after_create, it's after_create_commit. Here's the why, from first principles:
┌─────────────── DB TRANSACTION ───────────────┐
│ INSERT messages ... │
│ (other writes in the same txn) │
│ after_create ← fires HERE, INSIDE the txn │ ⚠ might still roll back!
└───────────────────────────────────────────────┘
COMMIT ← row is now durable
after_create_commit ← fires HERE, AFTER durable ✅
receive enqueues a background job and triggers a broadcast. If you used plain after_create and the transaction then rolled back, you'd have notified people about a message that no longer exists — a push to a phone for a ghost row. (This is exactly the bug from the cold open.) The single token _commit encodes "after durable" so that entire class of bug never gets a chance to exist. The naive version reaches for a flag like if @committed or starts manually wrapping things in transaction do ... end blocks and gets the partial-failure reasoning subtly wrong; Rails folds "after durable" into a suffix.
And the controller? Three real lines (messages_controller.rb:20-24):
def create
set_room
@message = @room.messages.create_with_attachment!(message_params)
@message.broadcast_create
This is the opposite of a fat controller. The controller doesn't orchestrate the unread-marking or the push fan-out — it persists, and persistence itself declares the consequence. The controller doesn't even know room.receive exists. (Notice the # Bots don't care easter egg on line 11 — humor as a signal that someone was actually thinking. Hold that client_message_id line in your mind; it returns as the star of Step 4.)
The contrast: The naive version (the one from the cold open) does @message = @room.messages.create(...) in the controller and then manually calls mark_unread(...) and enqueue_push(...) on the next lines — and now has to reason about: what if the row committed but the unread update threw halfway down the method? The fan-out is orchestration code sitting in the controller. Here it's a method call on the aggregate root, gated by _commit.
Step 2 — The Fan-out: cheap work in-band, slow work out-of-band
Follow room.receive (room.rb:46-49):
def receive(message)
unread_memberships(message)
push_later(message)
end
Two named intents. There's a clean line running right through the middle of these two lines — the sync/async line:
INSIDE the request (cheap, runs before HTTP response returns)
───────────────────────────────────────────────────────────
unread_memberships(message) → one bulk SQL UPDATE
broadcast_create (from ctrl) → Turbo-append to live screens
───────────────────────────────────────────────────────────
AFTER the request (slow, deferred to a background job)
push_later(message) → Room::PushMessageJob.perform_later
→ figure out who's offline, send OS pushes
Look at the in-band side (room.rb:67-74):
def unread_memberships(message)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end
def push_later(message)
Room::PushMessageJob.perform_later(self, message)
end
The entire "who needs an unread badge?" decision is one update_all over a chain of scopes — visible.disconnected.where.not(user: creator). No loop, no N+1, no fetching rows into Ruby to filter them. One SQL UPDATE marks everyone at once. Read it like a sentence: mark every visible, disconnected member who isn't the sender as unread.
But what does disconnected actually mean? Detour into Membership::Connectable (connectable.rb:4-19):
CONNECTION_TTL = 60.seconds
included do
scope :connected, -> { where(connected_at: CONNECTION_TTL.ago..) }
scope :disconnected, -> { where(connected_at: [ nil, ...CONNECTION_TTL.ago ]) }
end
"Online" is not a boolean column you have to keep in sync — it's a question about time. Are you online? Only if your connected_at falls inside a rolling 60-second window. And that question is expressed as a beginless/endless range right in the scope: connected_at: CONNECTION_TTL.ago.. (from 60s ago to now). disconnected is the gorgeous inverse — an array combining a nil check (never connected) with a half-open range ...CONNECTION_TTL.ago (last seen too long ago), both inside one where. The threshold is one named constant, not 60000 hardcoded in three files.
connected_at timeline →
──────────[ 60s ago ]────────────[ now ]──→
nil ◄── disconnected ──►│◄──── connected ────►│
(or older than 60s) │ (within the TTL) │
"Why a time window instead of a status flag?" Because flags lie. A flag says "online" forever if the user's laptop slams shut without a clean disconnect event. A 60-second window self-heals: stop touching
connected_atand you fade to disconnected automatically. The counter is bumped atomically (increment!(:connections, touch: true)), so concurrent tabs don't race. The naive version stores anonlineboolean (or alast_seen_at), then loads every membership into Ruby and filters in app memory withTime.current - m.last_seen_at < 60— moving a job the database does instantly into a Ruby loop, and racing the counter on every read-then-write.
Now the deferred, out-of-band side. push_later hands off to a job, which runs Room::MessagePusher. The "who gets an OS notification?" query (message_pusher.rb:56-60):
def relevant_subscriptions
Push::Subscription
.joins(user: :memberships)
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
end
Same visible.disconnected.where.not(user: creator) scopes, reused. And right above it, two methods that read like a product manager talking (message_pusher.rb:48-53):
def push_subscriptions_for_users_involved_in_everything
relevant_subscriptions.merge(Membership.involved_in_everything)
end
def push_subscriptions_for_mentionable_users(mentionees)
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
end
Membership.involved_in_everything. Membership.involved_in_mentions. Where do those English-reading scopes come from? One enum line (membership.rb:9):
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
That single declaration with prefix: :involved_in generates the whole family: the predicate membership.involved_in_everything?, the bang setter involved_in_everything!, and the class scope Membership.involved_in_everything. The .index_by(&:itself) trick stores the values as human-readable strings instead of integers — a deliberate choice for a column humans read. The naive version stores a plain string column and hand-writes where(involvement: "everything") at each call site plus an involved_in_everything? helper method plus a setter — every member of the family written by hand, all free to drift out of sync the day someone renames "everything" to "all". Rails derives the entire family from one line, and the result is a notification query that reads like the sentence you'd say in standup: push to subscriptions for users involved in everything.
The contrast: The naive version does everything inline in the request — insert the row, loop over every member, check whether they're online, branch on their prefs with nested ifs, and fire each push call one at a time — so the sender's request blocks on N push calls and one flaky push provider slows the whole send. Splitting it out later means standing up a background-job backend and a worker as new infrastructure you didn't plan for. Here, perform_later is one method call against machinery Rails already ships, and connection state itself drives the query so an open-room user never gets a redundant ping.
Step 3 — The Broadcast: one partial, three delivery paths, zero payload schema
Back in the controller, the third real line was @message.broadcast_create. Open the Broadcasts 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
It appends to the channel [room, :messages]. Now look at the view — the consumer of that broadcast (show.html.erb:15-20):
<%= messages_tag(@room) do %>
<%= render "rooms/show/invitation", room: @room %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
<%= turbo_stream_from @room, :messages %>
Line 20 subscribes to the literal same channel the model broadcasts to: turbo_stream_from @room, :messages. That's the entire wiring. No message bus to configure, no event emitter to register, no socket handler to write, no payload contract to keep in sync between two codebases.
And here's the zero-glue moment. Look at line 18: the initial page renders the message list with partial: "messages/message". The HTTP turbo-stream response to the sender renders the same messages/_message partial. And broadcast_append_to renders the same messages/_message partial into the websocket frame. One partial, three paths:
app/views/messages/_message (the ONE renderer)
/ | \
/ | \
initial page load HTTP response websocket frame
(you open the room) (to the sender) (to everyone else)
\ | /
\ | /
guaranteed byte-identical markup on every screen
There is no second renderer to drift. There is no JSON payload schema, because the wire doesn't carry data — it carries HTML the server already rendered. The thing that makes the naive version hard (keeping two renderings and a hand-built payload in sync) simply does not exist here. You cannot have a "the live version of the message looks different from the page version" bug, because there is only one version.
"But isn't sending HTML over the wire wasteful vs. a tiny JSON payload?" This is the trade DHH made on purpose. You spend a few extra bytes on the wire to delete an entire category of work: no client-side templating, no serializer, no contract, no second renderer, no divergence bugs. For a chat app the byte difference is noise; the maintenance difference is a whole subsystem you never build.
Step 4 — The Seam That Deletes a Whole Bug Class
This is the most elegant four lines in the flow, and it solves the exact bug from our cold open: the sender sees their own message twice.
Remember the optimistic render? The sender doesn't wait for the round-trip. The composer draws a pending node immediately (composer_controller.js:117-123):
const clientMessageId = this.#generateClientId()
await this.messagesOutlet.insertPendingMessage(clientMessageId, this.textTarget)
await nextFrame()
this.clientidTarget.value = clientMessageId
this.element.requestSubmit()
It generates a clientMessageId, draws a placeholder node with id="message_<clientMessageId>" (from _template.html.erb:3), stuffs that id into a hidden field, and submits. So the DOM already has a node identified by a UUID the client chose.
Now recall message.rb:11, the line directly above our flagship callback:
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
The server persists that client-chosen id (defaulting one only if absent — bots don't send one). And then, the magic, message.rb:27-29:
def to_key
[ client_message_id ]
end
That's it. That's the whole trick. to_key is the ActiveModel method that every identity helper in Rails consults. By overriding it to return the client_message_id instead of the database primary key, you've told the entire framework what "identity" means for a message. So dom_id(message) (used in messages_helper.rb:28: tag.div id: dom_id(message)) yields message_<client_message_id> — the same id the sender's optimistic placeholder already has.
SENDER'S SCREEN
───────────────
1. composer draws placeholder: <div id="message_abc123"> (optimistic)
2. POST carries client_message_id=abc123
3. server saves row, to_key → ["abc123"]
4. broadcast_append renders: <div id="message_abc123"> (authoritative)
│
Turbo append sees an EXISTING id "message_abc123"
│
▼
REPLACES the placeholder in place — does NOT stack a duplicate
The de-duplication of the sender's own message is not code anyone wrote in the send flow. Nobody wrote a reconciliation pass. Nobody tracked a temporary id to swap later. It falls out of one overridden method, because Turbo's append-with-an-existing-id semantics replace rather than duplicate, and the client and server agreed on identity from the very first keystroke. The convention (dom_id) does the heavy lifting; the developer just told it what identity means.
The contrast (the bug you no longer own): The naive version draws the optimistic node with a temporary id it made up locally, and then, when the authoritative message arrives over the wire with the real database id, it has to hand-write reconciliation: find the placeholder whose temp id matches, swap it for the real one — and if the live update arrives before the POST response that confirms the temp id, guard against drawing the message twice. That reconciliation is fiddly code that has to track which messages it has already seen, and it is a documented, classic source of duplicate-message and flicker bugs. Campfire deletes the entire problem by letting one ID, chosen on the client, be the row's identity everywhere.
Step 5 — The Decoupled Back-Half: behavior bolted onto a generic event
One thing the server's broadcast does not carry: anything about scrolling. The append is dumb — no scroll metadata, no "were you reading old messages?" awareness, no sound instruction. Good. Those are UI concerns, and the model must never learn a UI exists. So where does autoscroll live?
It rides on Turbo's own lifecycle event, wired with a single declarative string in a Rails helper (messages_helper.rb:70-72):
def messages_actions
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
end
That's a Stimulus data-action: when Turbo fires turbo:before-stream-render on document, call messages#beforeStreamRender. No addEventListener anywhere — the wiring is discoverable, declarative, and lives next to the markup. The handler (messages_controller.js:58-82):
async beforeStreamRender(event) {
const target = event.detail.newStream.getAttribute("target")
if (target === this.messagesTarget.id) {
const render = event.detail.render
const upToDate = this.#paginator.upToDate
if (upToDate) {
event.detail.render = async (streamElement) => {
const didScroll = await this.#scrollManager.autoscroll(false, async () => {
await render(streamElement)
await nextEventLoopTick()
this.#positionLastMessage()
this.#playSoundForLastMessage()
this.#paginator.trimExcessMessages(true)
})
if (!didScroll) {
this.latestTarget.hidden = false
}
}
} else {
this.latestTarget.hidden = false
}
}
}
Look at what it does: it grabs Turbo's own render function out of event.detail and wraps it — autoscroll, then render, then reposition the last message, play the sound, trim excess. And the nuance lives entirely here on the client: only autoscroll if the user was already near the bottom; otherwise just reveal the "jump to newest" pill (this.latestTarget.hidden = false). The transport mechanism (Turbo append) stays generic; behavior is layered on through a published lifecycle event.
server broadcast ─────► Turbo fires turbo:before-stream-render
(dumb HTML append) │
Stimulus wraps event.detail.render
│
┌───────────────────────────┼───────────────────────────┐
│ were you near the bottom? │ were you scrolled up? │
▼ │ ▼
autoscroll + sound + trim │ flash "jump to newest"
└───────────── model never learns any of this happened ──┘
The broadcast and the scroll behavior are completely decoupled. You can rewrite the scroll logic without touching the model; the model never has to know a UI exists. That separation isn't a happy accident — it's the payoff of putting the consequence in the model and the presentation of that consequence on the client.
The contrast: In the naive version, the scroll logic tangles into the same place that renders the incoming message: the one routine that draws the new message also checks "were they scrolled up?", scrolls, plays the sound, and runs the de-dupe — rendering, scroll position, sound, and reconciliation all piled into one imperative blob. Every concern bleeds into every other, so you can't touch the scroll behavior without risking the de-dupe, and the "were they near the bottom?" check leans on state that drifts out of sync with the render. Campfire keeps the layers from touching by bolting behavior onto a generic, published event.
The whole journey, on one screen
[Enter]
│
▼
composer_controller#submit ──► draws optimistic <div id="message_abc"> (Step 4)
│ sets hidden client_message_id = abc
▼
POST ──► MessagesController#create (3 real lines, Step 1)
│ @room.messages.create_with_attachment!
▼
INSERT messages (client_message_id=abc)
│
── COMMIT (durable) ──
│
┌────────┴─────────────────────────────────────────────┐
│ after_create_commit -> room.receive(self) (Step 1-2) │
│ ├─ unread_memberships → 1 bulk UPDATE (in-band) │
│ └─ push_later → background job (out-of-band) │
└────────┬──────────────────────────────────────────────┘
│ + controller calls @message.broadcast_create (Step 3)
▼
broadcast_append_to room,:messages ── renders messages/_message ──┐
│ │
┌────────┴────────┐ ┌─────────────┴───────────┐
▼ ▼ ▼ ▼
HTTP response websocket frame SENDER's screen OTHER screens
to sender to everyone else id "message_abc" already fresh append,
(same partial) (same partial) exists → Turbo REPLACES autoscroll via
│ │ placeholder, no dup before-stream-render
└─────────────────┴──────────► turbo:before-stream-render fires (Step 5) ──┘
→ autoscroll / sound / or "jump to newest" pill
One partial renders all three exits. One overridden to_key makes the sender's optimistic node and the authoritative append share a DOM identity. One _commit callback makes saving be the trigger. Count the files Campfire touched: a model, a partial, a controller action, a Stimulus controller. Now count the naive version's: a fat controller that orchestrates every consequence by hand, a pre-commit callback that fires for rows that roll back, two renderings of the message that drift apart, and a reconciliation pass to de-dupe the sender's own message — more moving parts, two renderers that diverge, and a blocking request that waits on every push.
That's the aha, stated plainly: the naive version rebuilds a pile of hand-orchestrated, drifting, blocking code to do what one durable callback, one reused partial, and one overridden identity method do here. (The JS-framework world, for what it's worth, rebuilds the same pile again with a stack of libraries — but you don't have to take that detour to feel the point.) Real-time isn't a feature you bolt on. It's what falls out when persistence, identity, and rendering each live in exactly the right place.
Key Takeaways — Patterns to Steal Tomorrow
- Let saving the row be the thing that triggers the fan-out, instead of standing in the controller after
createand hand-orchestrating "mark unread, then push, then broadcast" as a to-do list of consequences. The model is where the consequence belongs, so the controller can shrink to three honest lines. Campfire does it withafter_create_commit -> { room.receive(self) }inmessage.rb:12— persisting is the trigger, and the controller doesn't even knowroom.receiveexists. - The moment a callback reaches outside the database — a broadcast, a background job, a push, an email — reach for
after_create_commit, never plainafter_create. The plain one fires inside the transaction, so if the row later rolls back you've already notified a phone about a message that no longer exists, and patching around it with anif @committedflag or your owntransaction do ... endwrapper just gets the partial-failure reasoning subtly wrong. The single_commitsuffix inmessage.rb:12encodes "after durable" so that entire class of ghost-row bug never gets a chance to exist. - Make the model read as a table of contents, not a 600-line file with every behavior crammed in plus a
MessageServiceyou peel off when it gets scary. Theinclude Attachment, Broadcasts, Mentionee, Pagination, Searchableline is the spec — each concern lives atapp/models/message/<name>.rband wires its own callbacks through itsincluded doblock, so the host never has to know they exist.message.rb:2lets you read the whole surface area in twenty seconds and drill into exactly the one concern you care about. - Express "who needs an unread badge?" as a single bulk
update_allover a chain of scopes, not a loop that fetches every membership into Ruby and filters them one at a time with anif. One SQL UPDATE marks everyone at once — no N+1, no rows dragged into memory.room.rb:69does the whole decision inmemberships.visible.disconnected.where.not(user: message.creator).update_all(...), which reads like the sentence mark every visible, disconnected member who isn't the sender as unread. - Render the message through exactly one partial for every way it reaches a screen — the initial page load, the HTTP response to the sender, and the websocket frame to everyone else — instead of writing one rendering for the page and a second for the live update that were "the same" the day you wrote them and drift apart by the next feature. There is then no "the live version looks different from the page version" bug to have, because there is only one version. Campfire renders
messages/_messageon all three paths (show.html.erb:17plusbroadcast_append_to), so the markup is byte-identical everywhere by construction. - Let the wire carry server-rendered HTML rather than a tiny JSON payload that you then have to template on the other side. Yes, you spend a few extra bytes — but in exchange you delete the serializer, the payload contract you'd otherwise keep in sync between two codebases, the client-side template, and the divergence bugs that come with them. For a chat app the byte difference is noise;
broadcasts.rb:2-5sends the rendered partial straight into the frame, and the whole client-templating subsystem simply never gets built. - When a feature shows up as a verb — like deduping the sender's own optimistic message — find the convention that already governs identity instead of hand-writing a reconciliation pass that tracks a temporary id, swaps it for the real one, and guards the race when the live update beats the POST response. Override
to_keyto return the client-chosenclient_message_id(message.rb:27-29), and because every Rails identity helper consultsto_key,dom_id(message)now yieldsmessage_<client_message_id>— the same id the composer's optimistic placeholder already carries, so Turbo's append-with-an-existing-id semantics replace it in place. The de-dupe isn't written; it's deleted. - Draw a clean line between cheap work that can run in-band and slow work that must be deferred, instead of doing it all inline so the sender's request blocks while you loop over every member sending one push at a time and one flaky provider drags the whole send to a crawl.
room.rb:46-49puts the bulk unread UPDATE and the broadcast in-band, then hands the offline-detection-and-OS-push fan-out toRoom::PushMessageJob.perform_later(room.rb:73) — one method call against machinery Rails already ships, so the response returns before any push provider is touched.