Most "counter" demos stop at "click +1, see number tick." Useful for proving the framework works; not so useful when you actually have to ship one. This recipe goes past the demo into the production-shaped questions: how Subscribe + Publish route between the connections of a single session, why the cookie-bound session group matters for "multi-tab sync without leaking to other users," and what breaks first when this pattern meets real load.
The code is the same counter from Your First App — but the framing is different. Where that walkthrough builds the counter from scratch, this one stares at the five lines that do the actual work and unpacks them.
The whole thing fits in three files. State + controller in one (the part you'd write):
type CounterState struct {
Counter int
}
// CounterController holds shared dependencies (none in this demo) and
// exposes action methods invoked by name from the template.
type CounterController struct{}
// Mount subscribes the self-topic so peer tabs of the same session receive
// the Increment / Decrement dispatches Publish'd from the actions below.
func (c *CounterController) Mount(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return s, err
}
return s, nil
}
// Increment is invoked when the user clicks the "+1" button. The runtime
// calls it with a clone of the current state and stores whatever you return.
// The Publish call tells peer tabs subscribed to the same SelfTopic() to run
// Increment too, keeping multiple embeds and tabs in lockstep.
func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter++
if err := ctx.Publish(ctx.SelfTopic(), "Increment", nil); err != nil {
return s, err
And a wiring file that exposes an http.Handler:
// Handler returns the counter app as an http.Handler ready to mount.
// AnonymousAuthenticator gives each browser its own session group so
// public visitors get clean state on first visit; multi-tab broadcast
// still works within a single browser via the shared cookie. Callers
// supply environment-specific options (origin allowlists, dev mode)
// via opts — the recipe itself stays origin-agnostic so cmd/site can
// pass production hosts and cmd/main.go can pass localhost-permissive
// settings under --dev.
func Handler(opts ...livetemplate.Option) http.Handler {
baseOpts := []livetemplate.Option{
livetemplate.WithParseFiles(extractTemplate()),
livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{}),
}
tmpl := livetemplate.Must(livetemplate.New("counter", append(baseOpts, opts...)...))
return tmpl.Handle(&CounterController{}, livetemplate.AsState(&CounterState{}))
}
There's not much to it. The choices that matter for production are the two livetemplate.With* options. Everything else is mechanical.
AnonymousAuthenticator is the production defaultLiveTemplate's Authenticator interface answers a single question on every HTTP and WebSocket request: "who is this client, and which session group do they belong to?" The session group is what SelfTopic() resolves to — Subscribe(SelfTopic()) opts a connection into that group's topic, and Publish(SelfTopic(), ...) fans out to every subscribed connection in it. Two requests with the same group ID share the same topic string; different group IDs get different strings.
AnonymousAuthenticator (the framework's default, what this recipe uses) issues a cookie-bound group ID on first contact:
SelfTopic() → peer fan-out worksFor a public docs site, that's the right shape. Every reader gets their own private counter on first visit, can prove peer fan-out within their own browser, and the demo can't be polluted by a stranger's clicks.
The alternative — a constant-group authenticator that puts every visitor in one shared group — is a demo-flavored shortcut. It makes a global ticker visible to all visitors, which is punchy on a marketing page but fails the "clean slate for thousands of users" test. We used it briefly during early development; the production switch to AnonymousAuthenticator was a one-line change with no other code impact:
// Before — every visitor saw the same global counter
livetemplate.WithAuthenticator(sharedAuth{})
// After — each browser gets its own session group
livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{})
The Subscribe + Publish calls didn't change. The state struct didn't change. Only the routing rule for "who counts as the same session" changed, and that one swap converted a demo into a production-shaped widget.
Subscribe + Publish routeThe work happens in two places: Mount opts each connection in via ctx.Subscribe(ctx.SelfTopic()), and the action methods bump the counter and fan out via ctx.Publish(ctx.SelfTopic(), ...):
// the Increment / Decrement dispatches Publish'd from the actions below.
func (c *CounterController) Mount(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return s, err
}
return s, nil
}
// Increment is invoked when the user clicks the "+1" button. The runtime
// calls it with a clone of the current state and stores whatever you return.
// The Publish call tells peer tabs subscribed to the same SelfTopic() to run
// Increment too, keeping multiple embeds and tabs in lockstep.
func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter++
if err := ctx.Publish(ctx.SelfTopic(), "Increment", nil); err != nil {
return s, err
}
return s, nil
}
// Decrement follows the same pattern.
func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter--
if err := ctx.Publish(ctx.SelfTopic(), "Decrement", nil); err != nil {
return s, err
}
The two-step shape matters. Peer fan-out is opt-in — a connection that never called Subscribe would not receive the published action even if every other tab in the same group did. SelfTopic() resolves to a reserved-namespace topic string (lvt:session:<groupID>) that's ACL-exempt, so Subscribe(SelfTopic()) always succeeds and matches whatever other Mount-time Subscribe(SelfTopic()) calls produced in this session.
Publish("Increment", nil) adds an action to the per-action publish queue. It does not apply the action immediately to other connections; it queues it. After the current request's response is sent, the framework drains the queue: for every other connection that subscribed to this topic, run Increment against that connection's local state.
Three consequences worth knowing:
Subscribe, no fan-out. If you forget the Mount-side Subscribe(SelfTopic()), Publish runs without errors but reaches zero peer connections. The "my peer tabs stopped updating" troubleshooting question is almost always "did the receiver subscribe?"To prove the routing, here are two embeds against the same recipe app, side by side:
Click +1 on one. The other ticks too — same browser, same cookie, same group, ctx.Publish(ctx.SelfTopic(), ...) routes between them. Open this page in an incognito window: that incognito counter starts at zero and won't see your normal-window clicks. Different cookie, different group.
Worth pausing on what "session group" actually means in time.
AnonymousAuthenticator.GetSessionGroup issues a fresh group ID and sets it as a cookie. The connection joins that group.The group ID is the only thing tying a connection to its peers. Two browsers that somehow had the same cookie value would be in the same group. Two tabs from one browser are in the same group not because of the same TCP connection or anything similar — purely because of the shared cookie.
This recipe is a deliberately small slice. The scaling story behind it is real:
| Scenario | Works? | Notes |
|---|---|---|
| One user, multiple tabs, single instance | ✅ Trivially. The publish dispatch queue runs in-process, the cost is one Increment call per subscribed tab. |
|
| Multiple users, single instance | ✅ Each user has their own session group; SelfTopic() fan-outs stay scoped. |
|
| Multiple users, multiple instances (Fly machines, Kubernetes replicas) | ⚠️ Needs WithPubSubBroadcaster — by default a Publish only reaches connections on the same instance. With Redis-backed pubsub.Broadcaster the publish fans out across instances. See PubSub Reference. |
|
| One group with thousands of connections (everyone publishing at high frequency) | ❌ Publish cost is O(N) per action; thousand-connection groups publishing at 100Hz mean 100k+ in-process calls per second. Either shard the group or use a different sync primitive. | |
| Cross-user shared state (everyone sees everyone) | ⚠️ Possible — write a custom Authenticator that returns a constant group ID — but you've now built a write-amplification machine that any visitor can poke. Production examples need rate limiting, read-only modes, or moderation. |
AnonymousAuthenticator keeps you on the easy side of every row: per-user groups bound the fan-out, and the multi-instance question only matters once you've outgrown a single Fly machine.
The full handler in handler.go is just the constructor expressed as a function. It exists because this recipe is mounted by the docs site's cmd/site aggregator — there's no standalone main(). In your own app you'd write a main() that does the same thing inline (livetemplate.Must(...) → tmpl.Handle(...) → http.ListenAndServe) and call it a day. Exposing it as a Handler() constructor is just so it can be mounted inside another binary's HTTP server.
//go:embed counter.tmpl
var templateFS embed.FS
var (
tmplPath string
tmplOnce sync.Once
)
// extractTemplate writes the embedded template to a temp file so
// livetemplate's file-based loader can parse it at runtime. Done once
// per process. The temp dir survives until the OS reaps /tmp — this
// program does not delete it explicitly, which is fine because it's a
// few-KB file and the binary's lifecycle is the container's lifecycle.
func extractTemplate() string {
tmplOnce.Do(func() {
dir, err := os.MkdirTemp("", "counter-tmpl-*")
if err != nil {
log.Fatalf("counter: mkdtemp: %v", err)
}
data, err := templateFS.ReadFile("counter.tmpl")
if err != nil {
log.Fatalf("counter: read embedded tmpl: %v", err)
}
tmplPath = filepath.Join(dir, "counter.tmpl")
if err := os.WriteFile(tmplPath, data, 0o644); err != nil {
log.Fatalf("counter: write tmpl: %v", err)
}
})
return tmplPath
}
The embed.FS + temp-file dance at the top is a workaround for livetemplate.WithParseFiles taking filesystem paths — when the template ships inside the binary, we extract it once at first use. If you're running the standard "ship a directory of templates next to the binary" shape, you skip all this and pass the relative path directly.
Authenticator interface, beyond the anonymous default.Publish ordering rules and gotchas.