Guide

Broadcasting & live updates

Reactive actions update the acting user's screen; broadcasts update everyone else's — both target the component by its id, so they compose.

The pattern#

One render, one payload — the transport fans it to every subscriber; the actor is excluded from the echo.

Reactive actions update the acting user's screen. Broadcasts update everyone else's. Both target the component by its id, so they compose.

# Subscribe (in the view that should receive updates):
turbo_stream_from @list, :todos

# Broadcast (from a model callback, job, service, or a reactive action):
Todos::Item.broadcast_replace_to(@list, :todos, model: @todo)

The subscriber and broadcaster must agree on the same stream key. Pass the same *streamables to both turbo_stream_from and broadcast_*_to.

Two transports: Action Cable or pgbus#

Every broadcast_*_to routes over Turbo::StreamsChannel, so it works on either transport with no code change:

  • Action Cable — the Rails default. Broadcasts go over WebSockets. Nothing extra to install.
  • pgbus — Postgres-backed SSE. Installing it makes the SAME broadcast_*_to calls route over Postgres instead, and unlocks the transactional guarantee, per-connection exclude: / visible_to: scoping, and presence tracking below.

The plain broadcast API (replace / update / append / prepend / remove / js) is identical on both. What pgbus adds are the guarantees — the transactional deferral, actor-echo suppression, and presence — noted inline where they apply.

Stream keys: pass raw parts, not a built key#

broadcast_*_to(*streamables, ...) builds the stream key itself. Pass raw parts (a record and/or symbols), not an already-built key string:

# GOOD — raw parts
Chat::Message.broadcast_append_to("chat", room, target: "...", model: msg)
turbo_stream_from "chat", room

# BAD — double-keying ("chat:lobby" then re-keyed) trips the separator guard
key = ChatMessage.stream_key(room)             # => "chat:lobby"
Chat::Message.broadcast_append_to(key, ...)    # ArgumentError under pgbus

If you have a helper that returns a built key for the subscriber, pass the same built string to turbo_stream_from only — but give broadcast_*_to the raw parts. The simplest rule: *use the same raw streamables on both sides.

The broadcast methods#

  • .broadcast_replace_to(*streamables, model:, morph:) — replace the element with id component.id. Pass morph: true for a cross-tab morph (see below).
  • .broadcast_update_to(*streamables, model:, morph:) — replace its inner HTML. Pass morph: true for a cross-tab morph, so a peer editing the component keeps its focus/caret (see below).
  • .broadcast_append_to(*streamables, target:, model:) — append into container target.
  • .broadcast_prepend_to(*streamables, target:, model:) — prepend into target.
  • .broadcast_remove_to(*streamables, model:) — remove the element with id component.id.
  • .broadcast_js_to(*streamables, ops, exclude:, visible_to:, target:) — push client DOM ops (class/attr toggles, dispatch) to every subscriber. Refuses focus ops (see below).

Every one accepts the transport options exclude: and visible_to: (honored on pgbus, ignored on Action Cable — see those sections).

How model: maps to the init keyword#

The model: argument is passed to the component's initialize under the keyword model_param_name. For a record-backed component (reactive_record), that keyword is the record name — the SAME keyword the action endpoint uses to rebuild the component on a click. So a single initialize(<record>:) satisfies both clicks and broadcasts:

class Todos::Item < ApplicationComponent
  include Phlex::Reactive::Component
  reactive_record :todo
  def initialize(todo:) = @todo = todo   # the keyword must match `reactive_record :todo`
end

Todos::Item.broadcast_replace_to(@list, :todos, model: @todo) # builds new(todo: @todo)

For a Streamable-only component (broadcast-only, no reactive_record), model_param_name defaults to the demodulized, underscored class name. When the init keyword differs from that, override it:

class NotificationsBadge < ApplicationComponent
  include Phlex::Reactive::Streamable
  def initialize(user:) = @user = user
  def self.model_param_name = :user   # class name would be :notifications_badge
end

Render once, fan out to many keys (broadcast_*_to_each)#

broadcast_*_to concatenates its *streamables into one key. To push the same component to K different stream keys — a per-tenant loop, "the list page stream AND the dashboard stream" — a hand-written loop over broadcast_replace_to pays K builds + K renders + K identity HMACs for byte-identical HTML. broadcast_*_to_each renders once and loops only the cheap channel call:

# K renders + K HMACs — the cliff
accounts.each { |a| Counter.broadcast_replace_to(a, :counters, model: counter) }

# 1 render + 1 HMAC + K channel calls — ~9.5× faster at K=10, ~88% fewer allocations
Counter.broadcast_replace_to_each(
  accounts.map { [it, :counters] }, model: counter,
  exclude: reactive_connection_id
)
  • stream_keys is an enumerable of keys; each key is passed to the transport as its raw parts (a [record, :symbol] pair, or a bare string — both work). Same raw-parts rule as broadcast_*_to.
  • Every verb has an _each: broadcast_replace_to_each, broadcast_update_to_each, broadcast_append_to_each / broadcast_prepend_to_each (both take target:), and broadcast_remove_to_each.
  • Transport opts + morph: forward per keyexclude: / visible_to: are threaded to every stream exactly as the single-key verbs do, so the pgbus path suppresses the actor's echo on every stream and the Action Cable path is unchanged (the no-opts call passes no unknown keyword — the pgbus-optionality invariant holds).
  • The irreducible exception is per-VIEWER content. visible_to: that renders different HTML per viewer can't share a payload — that stays a render-per-call. _to_each is for the same payload to many keys.

Broadcasting from inside a reactive action#

The acting user gets the action's HTTP response (a replace of the component by default, or whatever reply the action returns). Everyone else gets the broadcast. Idiomorph dedupes a replace by DOM id, so the actor doesn't double-apply — but for append / prepend (and animations or optimistic UI) the echo would double-apply. Suppress the actor's own echo with exclude: reactive_connection_id:

def add(title:)
  authorize! @list, :update?
  todo = @list.todos.create!(title:)
  Todos::Item.broadcast_append_to(
    @list, :todos,
    target: dom_id(@list, :todos),
    model: todo,
    exclude: reactive_connection_id # don't echo to the actor — they got the HTTP response
  )
end

Actor-echo suppression (exclude:)#

reactive_connection_id is the acting client's SSE connection id during the action (nil when the client isn't subscribed to a stream, or outside an action). The client sends it as X-Pgbus-Connection; the action endpoint exposes it. Passing it as exclude: tells the transport to skip delivery to that one connection — so the actor's truth is the HTTP response and they never get a duplicate.

  • With pgbus: fully honored — the dispatcher skips the excluded connection (pgbus ≥ the streams-reactive release).
  • With Action Cable: exclude: is accepted but ignored (Action Cable has no per-connection exclusion); rely on idiomorph dedup for replace / update.

This is what makes optimistic UI safe: apply the change locally, broadcast with exclude:, and the actor never gets a conflicting echo of their own action.

Scoping a broadcast (visible_to:)#

Where exclude: names ONE connection to skip, visible_to: names the only connections that should receive the broadcast — an allow-list rather than a deny-one. It is a first-class transport option on every broadcast_*_to, alongside exclude::

# Only these two connections see the update — everyone else on the stream
# is skipped. Useful for a private DM row, a per-seat highlight, or a
# moderator-only control.
Chat::Message.broadcast_replace_to(
  room, :messages,
  model: msg,
  visible_to: [alice_connection_id, bob_connection_id]
)

Like exclude:, visible_to: is honored on pgbus (per-connection delivery) and ignored on Action Cable (which has no per-connection scoping — every subscriber of the stream receives the broadcast).

Cross-tab morphing (broadcast_replace_to morph:)#

A plain broadcast_replace_to swaps the whole element. If a peer viewer is mid-interaction on that element — a text caret in an input, an open <details>, scroll position — a swap throws that state away. Pass morph: true to make the replace morph in place instead, so the peer tab keeps its focus and caret on the morphed row:

# A peer tab editing the same row keeps its caret; only the changed
# attributes/text are patched in.
Todos::Item.broadcast_replace_to(@list, :todos, model: @todo, morph: true)

This emits method="morph" on the broadcast <turbo-stream>, so idiomorph patches the existing DOM rather than replacing it.

broadcast_update_to takes morph: too — a plain update swaps the inner HTML (still tearing down a focused input mid-edit), so morph: true patches the inner HTML in place instead, keeping a peer's caret:

Totals.broadcast_update_to(@order, :totals, model: @order, morph: true)

append / prepend / remove don't replace an existing element, so they take no morph:.

Transactional broadcasts (with pgbus)#

The action endpoint runs your action inside a transaction. With pgbus, broadcasts defer to after_commit, so:

  • A broadcast inside a transaction that rolls back never firesand the DB change is undone. No phantom UI update for a change that didn't happen.
  • This is the correctness property neither Action Cable nor Livewire give you cleanly.
ActiveRecord::Base.transaction do
  @order.update!(status: "shipped")
  Orders::Card.broadcast_replace_to(@order.account, model: @order)  # deferred
  ChargeService.capture!(@order)   # if this raises → no broadcast, no status change
end

Removing the actor's own element#

destroy-style actions are the one case where "replace the component by its id" doesn't fit — the element should vanish, not be replaced. Return reply.remove from the action: the actor's element is removed via the built-in Streamable#to_stream_remove (no endpoint override, no helper to add), and other tabs get broadcast_remove_to(..., exclude: reactive_connection_id).

def destroy
  authorize! @todo, :destroy?
  list = @todo.list
  @todo.destroy!
  # other tabs
  Todos::Item.broadcast_remove_to(list, :todos, model: @todo, exclude: reactive_connection_id)
  reply.remove # this tab
end

For an "undo" affordance, replace with a tombstone state instead of removing. See the reply API for the full set of reply controls.

Pushing client DOM ops to everyone (broadcast_js_to)#

Sometimes a broadcast should nudge the UI rather than swap HTML — light up a bell, toggle a class, dispatch an app event. broadcast_js_to pushes the same js ops as reply.js to every subscriber, over the same transport (Action Cable or pgbus):

# Light up the bell in every viewer's tab, minus the actor's own connection.
Notifications::Badge.broadcast_js_to(user, :alerts,
  js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
Focus ops are refused. broadcast_js_to raises ArgumentError for focus/focus_first — broadcasting focus would steal it in every subscriber's tab. Focus is an actor-reply concern (use reply.js).

Nudging the actor only (reply.js)#

broadcast_js_to is the everyone half. Its actor-side sibling is reply.<verb>.js(ops) — chained onto the reply the action returns, it runs client DOM ops on the acting user's tab only, and it does so AFTER the render streams. Because it rides after the render, a focus op sees the freshly rendered (or morphed) DOM:

def save(title:)
  @todo.update!(title:)
  # Morph in place, THEN focus the next field + tell a toast host we saved.
  # `focus` is legal here — it's the actor's OWN tab, not a broadcast.
  reply.morph.js(js.focus("[name=next_field]").dispatch("app:saved", detail: { id: @todo.id }))
end

target: scopes op resolution on the client; it defaults to the bound component's id for replace / morph / update (so @root and component-relative selectors just work), and to document-scope for a subject-free reply.with. This is why the focus example above is an actor-reply concern and broadcast_js_to refuses focus outright: reply.js targets one tab, a broadcast targets all of them.

Presence (who's here / typing)#

pgbus ships presence tracking. Join on render, leave on disconnect, broadcast the change:

Pgbus.stream(@room).presence.join(
  member_id: current_user.id,
  metadata: { name: current_user.name }
) do |member|
  Chat::PresencePill.replace(member: member)  # rendered HTML to broadcast
end

Pgbus.stream(@room).presence.members   # current list
Pgbus.stream(@room).presence.count     # fast count for a "N online" badge
See the pgbus transport guide for the full presence and SSE story.