Server push

Most updates start with a client: a user clicks, the action runs, the framework diffs and patches. Server push is for the other case — when server-owned work finishes and needs to reach a session's live connections without any client request: a background job completes, a subscription delivers an event, a timer ticks.

session.TriggerAction(action, data) dispatches a named action into all of a session's live connections, exactly as if the client had invoked it — the same diff-and-patch pipeline, just enqueued from the server side.

Slow work started by a click does not belong here. If a user action kicks off the work and its result goes back to the same connection, use livetemplate.Async instead: it runs the work off the event loop, re-enters the loop to apply the result, and cancels itself if the connection drops — one method, no goroutine and no second action to name. Server push is for work the client did not ask for.

Everything on this page remains the right tool when the work starts outside an action handler (Mount, OnConnect, a webhook, a timer), reports progress repeatedly rather than completing once, or must reach connections other than the one that started it. Async covers none of those — it is action-handler-only, one-shot, and scoped to the originating connection.

Triggering an action from server-owned work

Grab the session with ctx.Session() while you're still on a connection, then hand it to whatever runs later — a goroutine, a timer callback, a job-result handler:

func (c *Controller) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
    session := ctx.Session()
    go func() {
        result := fetchSlowData()                 // background work, no client involved
        _ = session.TriggerAction("DataLoaded", map[string]any{"value": result})
    }()
    return state, nil
}

func (c *Controller) DataLoaded(state State, ctx *livetemplate.Context) (State, error) {
    state.Value = ctx.GetString("value")          // ordinary action — re-render from new state
    return state, nil
}

DataLoaded is a normal action method; the only difference is who enqueued it. Typical sources of a server push: a completed background job, an incoming subscription or webhook event, a periodic timer.

Routed by session, not by topic

TriggerAction reaches a session's connections by group ID — it does not use topics, and the receiver does not need to have called Subscribe. That makes it independent of Pubsub: pubsub is "an action fans out to peers who opted in via a topic"; server push is "server-owned work pushes to one session's connections."

Fanning out to many sessions at once

TriggerAction targets one session group. When a single background event must refresh many sessions — every viewer of a shared dashboard, every tab of every user — don't keep a registry of Session handles and loop over it. Two calls give you a fan-out with no registry at all:

  1. Every connection joins a shared topic in Mount with ctx.Subscribe(topic) (reconnect-durable, because Mount re-runs on reconnect).
  2. A background goroutine calls the handler's out-of-band handler.Publish(topic, action, data) — no Context, safe from anywhere. Every subscriber, across every session group, re-runs action and re-renders.

The demo below runs one time.Ticker. On each tick it updates shared state and calls handler.Publish("dashboard", "Refresh", nil). Open it in two tabs — both advance in lockstep, driven by that single goroutine, with no per-tab timer.

Live Ops Dashboard

Every metric below is pushed by a single background goroutine to every connected browser — no per-tab timer, no Session-handle registry. Open this page in a second tab and watch both update in lockstep.

Pushes received
87716
Simulated active jobs
4
Last server push
08:33:14

The server runs one time.Ticker. On each tick it updates shared state and calls handler.Publish("dashboard", "Refresh", nil); the framework re-renders every subscriber.

Because a shared topic is cross-user, subscribing to a developer topic is deny-all by default — the handler authorizes it with WithTopicACL, the one security boundary such a topic has. (Joining your own ctx.SelfTopic() to reach just your own tabs needs no ACL.) See the server actions reference for the full pattern and the per-user vs shared-group distinction.

Need Use
A background goroutine / timer / job should push to one session's connections session.TriggerAction("...", data) (this page)
A background goroutine should refresh many sessions at once ctx.Subscribe(topic) in Mount + out-of-band handler.Publish(topic, ...) (above)
A user action should also update peer tabs after it succeeds PubsubSubscribe / Publish
The current connection should update from its own action Return the new state from the action

What next?

source: livetemplate/docs · path: content/recipes/server-push.md