Presence Tracking

Track how many users are currently online. Explicit Join and Leave actions mutate a mutex-guarded user map on the controller, then each calls ctx.Publish(ctx.SelfTopic(), "PresenceChanged", nil) so every subscribed peer recomputes its OnlineCount from the shared map. Username and Joined stay per-connection — peers update only the count, never another connection's identity.

Presence Tracking

Explicit Join/Leave actions update a mutex-guarded user map on the controller. Each mutation broadcasts PresenceChanged to peers, who recompute their local OnlineCount from the shared map.

0 user(s) online

Limitations (this is a pattern demo; production apps need more care):

  • Close-tab leak: Closing a tab without clicking Leave leaves the user in the online set — the framework's OnDisconnect() hook receives no state or context, so it can't identify which user disconnected. Production apps either track connections explicitly or run a heartbeat sweep.
  • Same-username collision: The map keys on username, so two tabs joining as the same name register as one entry. When EITHER tab clicks Leave, the count drops to 0 even though the other tab is still "online" — connection-keyed tracking is the proper fix.

Template

A live count plus a join form that swaps for a Leave button once you're in.

{{define "content"}}
<article>
    <h3>Presence Tracking</h3>
    <p><small>Explicit Join/Leave actions update a mutex-guarded user map on the controller. Each mutation broadcasts <code>PresenceChanged</code> to peers, who recompute their local <code>OnlineCount</code> from the shared map.</small></p>

    <p><mark aria-live="assertive">{{.OnlineCount}} user(s) online</mark></p>

    {{if .Joined}}
    <p>Logged in as <strong>{{.Username}}</strong></p>
    <form method="POST">
        <button name="leave" class="compact secondary">Leave</button>
    </form>
    {{else}}
    <form method="POST">
        <fieldset role="group">
            <input name="username" placeholder="Pick a name…" aria-label="Username" required>
            <button name="join">Join</button>
        </fieldset>
    </form>
    {{end}}

    <p><small><strong>Limitations</strong> (this is a pattern demo; production apps need more care):</small></p>
    <ul>
        <li><small><strong>Close-tab leak:</strong> Closing a tab without clicking Leave leaves the user in the online set — the framework's <code>OnDisconnect()</code> hook receives no state or context, so it can't identify which user disconnected. Production apps either track connections explicitly or run a heartbeat sweep.</small></li>
        <li><small><strong>Same-username collision:</strong> The map keys on username, so two tabs joining as the same name register as one entry. When EITHER tab clicks Leave, the count drops to 0 even though the other tab is still "online" — connection-keyed tracking is the proper fix.</small></li>
    </ul>
</article>
{{end}}

presence.tmpl

Handler & state

Mount subscribes and seeds the count; Join/Leave mutate the shared map and publish; PresenceChanged refreshes only the count on peers.

type PresenceController struct {
	mu          sync.RWMutex
	onlineUsers map[string]bool
}

func newPresenceController() *PresenceController {
	return &PresenceController{onlineUsers: make(map[string]bool)}
}

// Mount runs on every initial render. Subscribing the self-topic wires this
// connection to receive PresenceChanged from peer Publishes. Without the
// initial-render OnlineCount read, a new visitor's state.OnlineCount would
// default to 0 even when other users are already in the shared map — they'd
// see "0 user(s) online" until the next Join/Leave publish updates them.
func (c *PresenceController) Mount(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) {
	if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
		return state, err
	}
	c.mu.RLock()
	state.OnlineCount = len(c.onlineUsers)
	c.mu.RUnlock()
	return state, nil
}

func (c *PresenceController) Join(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) {
	name := strings.TrimSpace(ctx.GetString("username"))
	if name == "" {
		return state, nil
	}
	c.mu.Lock()
	c.onlineUsers[name] = true
	state.Username = name
	state.Joined = true
	state.OnlineCount = len(c.onlineUsers)
	c.mu.Unlock()
	if err := ctx.Publish(ctx.SelfTopic(), "PresenceChanged", nil); err != nil {
		return state, err
	}
	return state, nil
}

func (c *PresenceController) Leave(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) {
	if state.Username == "" {
		return state, nil
	}
	c.mu.Lock()
	delete(c.onlineUsers, state.Username)
	state.Username = ""
	state.Joined = false
	state.OnlineCount = len(c.onlineUsers)
	c.mu.Unlock()
	if err := ctx.Publish(ctx.SelfTopic(), "PresenceChanged", nil); err != nil {
		return state, err
	}
	return state, nil
}

// PresenceChanged refreshes only the shared OnlineCount. Username and
// Joined are per-connection identity and must NOT be overwritten from a
// peer publish — every connection's own Join/Leave is the only thing
// that mutates those fields locally.
func (c *PresenceController) PresenceChanged(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) {
	c.mu.RLock()
	state.OnlineCount = len(c.onlineUsers)
	c.mu.RUnlock()
	return state, nil
}

func presenceHandler() http.Handler {
	tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/realtime/presence.tmpl")
	return tmpl.Handle(newPresenceController(), livetemplate.AsState(&PresenceState{
		Title:    "Presence Tracking",
		Category: "Real-Time & Multi-User",
	}))
}

handlers_realtime.go:154-229

type PresenceState struct {
	Title    string
	Category string
	// Username + Joined are intentionally NOT lvt:"persist" — see comment on
	// BroadcastingState.Username. Tabs need independent presence identity.
	Username    string
	Joined      bool
	OnlineCount int
}

state_realtime.go:35-44

When to use

Reach for Broadcasting when peers need to exchange messages, not just presence.

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