Broadcasting

A message sent in one tab appears in every other tab that joined. The shared log lives on the controller behind a mutex; Mount subscribes each connection with ctx.Subscribe(ctx.SelfTopic()) and snapshots the log into local state. Send appends under the lock, releases, then ctx.Publish(ctx.SelfTopic(), "NewMessage", nil) fans the action out so every subscribed peer re-reads the log. Username is per-connection — deliberately not persisted — so two tabs can join as different users.

Broadcasting

Mount calls ctx.Subscribe(ctx.SelfTopic()) to opt this connection in to peer fan-out; Send then calls ctx.Publish(ctx.SelfTopic(), "NewMessage", nil) to fan the named action out to every other connection that subscribed. Peers receive it as a regular action invocation; their handler reads the shared message log under a mutex and refreshes local state. The publish is queued during the action and executes after it returns successfully.

Try: Open this page in a second tab and Join with a different name. Sending in either tab publishes to both subscribed peers. The shared log lives on the controller; each tab's Username is per-connection (not persisted) so two tabs in the same browser stay independent — see Reconnection Recovery for the persist case.

Limitation: The shared message log is in-memory and uncapped — production apps would ring-buffer, paginate, or persist to a TTL store. Kept simple here to focus on the Subscribe/Publish mechanism itself.

Template

A join form swaps for the message list plus a send form once the user has a name.

<h3>Broadcasting</h3>
<p><small>Mount calls <code>ctx.Subscribe(ctx.SelfTopic())</code> to opt this connection in to peer fan-out; Send then calls <code>ctx.Publish(ctx.SelfTopic(), "NewMessage", nil)</code> to fan the named action out to every other connection that subscribed. Peers receive it as a regular action invocation; their handler reads the shared message log under a mutex and refreshes local state. The publish is queued during the action and executes after it returns successfully.</small></p>

{{if eq .Username ""}}
<form method="POST">
    <fieldset role="group">
        <input name="username" placeholder="Pick a name…" aria-label="Username" required>
        <button name="join">Join</button>
    </fieldset>
</form>
{{else}}
<p><small>Posting as <strong>{{.Username}}</strong></small></p>

<div class="messages" lvt-fx:scroll="bottom-sticky">
    {{range .Messages}}
    <p data-key="{{.ID}}"><strong>{{.User}}:</strong> {{.Text}}</p>
    {{else}}
    <p><small><em>No messages yet — send one to publish it to all subscribed peers.</em></small></p>
    {{end}}
</div>

<form method="POST">
    <fieldset role="group">
        <input name="text" placeholder="Type a message…" aria-label="Message" required>
        <button name="send">Send</button>
    </fieldset>
</form>
{{end}}

broadcasting.tmpl:4-31

Handler & state

Mount subscribes and seeds the log; Send mutates-then-publishes after releasing the lock, and peers run NewMessage to converge.

type BroadcastingController struct {
	mu       sync.RWMutex
	nextID   int
	messages []BroadcastMessage
}

// snapshotLocked returns a copy of c.messages. The Locked suffix signals
// that the caller MUST hold c.mu (read or write) — without that, slices.Clone
// reads c.messages concurrently with Send's append and races.
func (c *BroadcastingController) snapshotLocked() []BroadcastMessage {
	return slices.Clone(c.messages)
}

func (c *BroadcastingController) Mount(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) {
	if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
		return state, err
	}
	c.mu.RLock()
	state.Messages = c.snapshotLocked()
	c.mu.RUnlock()
	return state, nil
}

handlers_realtime.go:70-92

Send appends under the lock, releases it, then publishes so every peer converges:

func (c *BroadcastingController) Send(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) {
	if state.Username == "" {
		return state, nil
	}
	text := strings.TrimSpace(ctx.GetString("text"))
	if text == "" {
		return state, nil
	}
	c.mu.Lock()
	c.nextID++
	// No cap on c.messages: deliberately omitted to keep the demo focused
	// on the Publish-to-SelfTopic mechanism. Production apps would
	// ring-buffer, paginate, or persist to a store with TTL.
	c.messages = append(c.messages, BroadcastMessage{ID: c.nextID, User: state.Username, Text: text})
	state.Messages = c.snapshotLocked()
	c.mu.Unlock()
	// Publish must come after the lock release — holding the connection
	// registry mutex while queuing peer dispatches can deadlock with peer
	// dispatches that take the same mutex from the other side. Peers
	// receive "NewMessage" and refresh their local copy.
	if err := ctx.Publish(ctx.SelfTopic(), "NewMessage", nil); err != nil {
		return state, err
	}
	return state, nil
}

handlers_realtime.go:105-130

func (c *BroadcastingController) NewMessage(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) {
	c.mu.RLock()
	state.Messages = c.snapshotLocked()
	c.mu.RUnlock()
	return state, nil
}

handlers_realtime.go:134-140

type BroadcastingState struct {
	Title    string
	Category string
	// Username is intentionally NOT lvt:"persist" — persist storage is keyed
	// by session group (state.go:1421 SessionStore.Set(ctx, groupID, ...)),
	// so persisting it would force every tab in the same browser to share a
	// single Username. The whole point of the demo is letting two tabs join
	// as different users; per-connection state is what makes that work.
	// Reconnect Recovery (#29) covers the persist scenario instead.
	Username string
	Messages []BroadcastMessage
}

state_realtime.go:19-31

When to use

For the full deep-dive on the mutex rules and pub/sub scope, see Broadcasting, deeper. Use Presence Tracking when you only need to know who is currently connected.

source: livetemplate/docs · path: examples/patterns/templates/realtime/broadcasting.tmpl