Drive the UI from the server with no client-side polling. StartTimer spawns a
background goroutine that calls session.TriggerAction(name, data) once per second,
firing an action on the originating connection so the re-rendered tree is pushed to
the browser. TriggerAction returns an error when the session group has no live
connections — checking it each tick is the documented cancellation pattern, so the
goroutine exits cleanly if the user closes the tab.
Server Push
session.TriggerAction(name, data) fires an action on the originating connection from a background goroutine. The handler updates state and the rendered tree is pushed to the browser. The call returns a non-nil error when the session group has no live connections — checking it on every iteration is the documented goroutine-cancellation pattern (no done channel needed).
Try: Click Start and watch the counter tick up once per second. The goroutine on the server pushes each tick via session.TriggerAction("tick", {elapsed: …}). If you close the tab mid-run, the next TriggerAction returns an error and the goroutine exits — no leak.
Template
A Start button that flips to a live "running" view as the server pushes each tick.
{{define "content"}}
<article>
<h3>Server Push</h3>
<p><small><code>session.TriggerAction(name, data)</code> fires an action on the originating connection from a background goroutine. The handler updates state and the rendered tree is pushed to the browser. The call returns a non-nil error when the session group has no live connections — checking it on every iteration is the documented goroutine-cancellation pattern (no done channel needed).</small></p>
{{if .Running}}
<p aria-busy="true">Timer running: <strong>{{.Elapsed}}</strong> / {{.Total}}s</p>
{{else}}
<form method="POST">
<button name="startTimer">Start Timer</button>
</form>
{{if gt .Elapsed 0}}
<p><small>Last completed: <strong>{{.Elapsed}}s</strong></small></p>
{{end}}
{{end}}
<p><small><strong>Try:</strong> Click Start and watch the counter tick up once per second. The goroutine on the server pushes each tick via <code>session.TriggerAction("tick", {elapsed: …})</code>. If you close the tab mid-run, the next <code>TriggerAction</code> returns an error and the goroutine exits — no leak.</small></p>
</article>
{{end}}
StartTimer launches the ticker goroutine; Tick and TimerDone are the actions
the goroutine fires to update state.
type ServerPushController struct{}
const serverPushTickInterval = 1 * time.Second
const serverPushTickCount = 10
// StartTimer flips state.Running and spawns a 10×1s ticker goroutine.
//
// Running is intentionally NOT lvt:"persist". If the connection drops
// mid-timer (browser refresh, network blip), the reconnected client
// will see Running=false in its initial render — the goroutine on the
// server keeps ticking and eventually fires TimerDone, so the client
// will pop directly to the "Last completed: Xs" view rather than the
// running view. Trade-off: a persisted Running flag could survive the
// reconnect, but a stale "Running=true" with no in-flight goroutine is
// a worse failure mode (the UI would be stuck forever waiting for ticks
// that aren't coming).
func (c *ServerPushController) StartTimer(state ServerPushState, ctx *livetemplate.Context) (ServerPushState, error) {
if state.Running {
return state, nil
}
// Check session BEFORE flipping Running. Framework guarantees a
// session for WebSocket connections, but if it ever is nil we'd
// render "Timer running" with no goroutine to ever clear it.
session := ctx.Session()
if session == nil {
return state, nil
}
state.Running = true
state.Elapsed = 0
state.Total = serverPushTickCount
go func() {
ticker := time.NewTicker(serverPushTickInterval)
defer ticker.Stop()
for i := 0; i < serverPushTickCount; i++ {
<-ticker.C
// session.TriggerAction returns an error when the session group has
// no live connections (livetemplate/session_impl.go:91-159). Bail
// out cleanly so the goroutine exits when the user closes the tab.
if err := session.TriggerAction("tick", map[string]any{
"elapsed": i + 1,
}); err != nil {
return
}
}
// timerDone fires after the loop completes. We discard the error
// here (unlike the per-tick error check above): if the connection
// is gone by now, the goroutine exits anyway — no recovery action
// is meaningful, and propagating would force the caller of the
// goroutine to handle a context that's already finished.
_ = session.TriggerAction("timerDone", nil)
}()
return state, nil
}
func (c *ServerPushController) Tick(state ServerPushState, ctx *livetemplate.Context) (ServerPushState, error) {
state.Elapsed = ctx.GetInt("elapsed")
return state, nil
}
func (c *ServerPushController) TimerDone(state ServerPushState, ctx *livetemplate.Context) (ServerPushState, error) {
state.Running = false
return state, nil
}
func serverPushHandler() http.Handler {
tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/realtime/server-push.tmpl")
return tmpl.Handle(&ServerPushController{}, livetemplate.AsState(&ServerPushState{
Title: "Server Push",
Category: "Real-Time & Multi-User",
}))
}
type ServerPushState struct {
Title string
Category string
Running bool
Elapsed int
// Total is set in StartTimer to mirror the Go-side serverPushTickCount
// constant. Rendering it from state (rather than hardcoding "10s" in
// the template) keeps the template in sync if the constant changes.
Total int
}