Counter, deeper

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.

Counter: 0

Anatomy of the handler

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

counter.go:9-33

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{}))
}

handler.go:49-64

There's not much to it. The choices that matter for production are the two livetemplate.With* options. Everything else is mechanical.

Why AnonymousAuthenticator is the production default

LiveTemplate'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:

For 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.

How Subscribe + Publish route

The 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
	}

counter.go:18-43

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:

To prove the routing, here are two embeds against the same recipe app, side by side:

Counter: 0

Counter: 0

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.

Session group lifecycle

Worth pausing on what "session group" actually means in time.

  1. First visit: the browser has no cookie. AnonymousAuthenticator.GetSessionGroup issues a fresh group ID and sets it as a cookie. The connection joins that group.
  2. Subsequent requests (next tab, page refresh, WebSocket reconnect): the cookie is sent, the same group ID is returned, the connection joins the existing group.
  3. Cookie cleared / different browser: a new group ID is issued. Old state is unreachable from the new group.
  4. Server restart: cookies persist but in-memory session state is gone. New connections start fresh; the publish dispatch queue is empty until clients reconnect and trigger new actions.

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.

When this pattern scales — and when it doesn't

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.

What the wiring file actually does

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
}

handler.go:18-47

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.

What next?

source: livetemplate/docs · path: content/recipes/counter/index.md