Live dashboards
A live dashboard is the canonical composition of GoFastr's push primitives: server-rendered charts and StatCards, a bounded activity feed, a keyed changing collection, an authoritative refresh endpoint, and the connection-health banner. This doc is the reference for how those primitives fit together, and the boundaries that decide when each is the right shape versus when you need a durable queue.
The runnable proof lives at
/examples/live-dashboard
(examples/site). Open it in two browsers: both see the same metric
ticks, because pushes are broadcast to every session that joined the
live-dashboard-demo presence topic.
What the example demonstrates
The dashboard composes existing primitives: no bespoke CSS, no new runtime JS. Each region maps to one product job:
| Region | Primitive | What it proves |
|---|---|---|
| Metric StatCards (throughput, workers, queue depth, p99) | ui.StatCard inside a data-island slot | Server re-renders fresh HTML on each tick and PushUpdates it; the runtime swaps just that region's innerHTML. The cards are NOT aria-live because high-frequency numbers would flood assistive tech. |
| Activity feed | ui.Timeline in a data-island slot with role="status" | The polite announcement lane. The server trims to the N most recent entries before each push. The client never buffers. |
| Jobs table | ui.DataTable (rows keyed by Row.ID) in a data-island slot | A keyed changing collection. Successive pushes produce HTML that differs only on rows that actually changed. |
| Operational status pill | store.Computed bound via Slice.Bind | A derived client-side value. The operator's +/- buttons mutate two signals with data-cui-signal-inc; the reducer runs in the browser and the pill updates live. No RPC. |
| Connection-health banner | ui.NetworkRetryBanner | Follows the connection the runtime reports: it ships hidden and shows when the SSE lane is lost with a retry scheduled; the Retry button probes HealthEndpoint and hides it on a 2xx. HealthEndpoint is required and must be a relative path or an http(s) URL; the banner panics on anything else (a javascript: value included). |
| Reconnect refresh | GET /__site/livedash/refresh?island=… | The islands are reconstructable. SSE is lossy; on reconnect an app fetches the current island HTML to reconcile. The runtime does not do this for you. |
| Topic-scoped delivery | host.Islands.PresenceSessions(topic) | Only sessions that joined the topic receive pushes. The demo uses ONE fixed topic because it has no identity; a multi-tenant app derives a tenant-qualified topic per authenticated user (see Tenant isolation). |
The wiring in one paragraph
The screen declares live-dashboard-demo through PresenceTopics (the
uihost.PresenceScreen interface). handlePage signs that list into the
SSE <meta> tag so the client's EventSource opens
/__gofastr/sse?session=X&declared=live-dashboard-demo&dsig=…. The
server-side join happens at SSE-connect time, once the signature checks
out. A background ticker advances the demo
state under a mutex, re-renders three islands from the snapshot, and
calls host.Islands.PushUpdate(IslandUpdate{IslandID, HTML}, sessionID)
for every session on the topic. The runtime's sse module reflects each
"island" SSE event into the matching [data-island="<id>"] region,
swapping its innerHTML. No client-side data fetch, no full reload, no
re-render of unrelated page state.
mgr.SetOnPresenceChange(func(topic string) { rosterHTML := string(renderRoster(mgr.PresenceRoster(topic))) for _, sid := range mgr.PresenceSessions(topic) { mgr.PushUpdate(island.IslandUpdate{ IslandID: "roster-" + topic, HTML: rosterHTML, }, sid) } })
The dashboard's ticker uses the identical shape. It just fires on a timer instead of on a roster change.
Update scheduling and DOM discipline
This section is documentation, not a thing to build. The runtime already implements every discipline below per-feature; reach for the same patterns when an island you own needs them.
Coalesce burst updates into one rAF flush
A signal change that mutates the DOM more than once per frame is wasted
work. The runtime's rAF-throttled modules (backtotop, popover,
scrollspy, toc, dropdown) all use the same shape: a boolean
_rafPending gate, a single requestAnimationFrame callback that walks
the pending set, then clears the gate. The pattern in popover.js is
the cleanest example: capture-phase scroll listener, one place() per
frame even on a furious wheel-spin.
let rafPending = false; scrollEl.addEventListener('scroll', () => { if (rafPending) return; rafPending = true; requestAnimationFrame(() => { rafPending = false; place(); }); }, { capture: true, passive: true });
For island pushes, the runtime already batches per SSE frame: one
el.innerHTML = html per island event. If your island handler emits
multiple signal updates that touch the same region, fold them into one
re-render on the server side and push once.
Latest-value for metrics, bounded append for feeds
These are two different update shapes and they must not share a region:
- Latest-state metric (throughput, queue depth). The new value
replaces the old. Intermediate frames may drop without harming the
user. A future tick heals a missed one. This is the default SSE
contract (
?buffer=Nwith oldest-drop). - Bounded append feed (activity log, audit trail). New entries
prepend/append; old entries trim. The server trims to the N most
recent before re-render, never the client. The dashboard caps the
feed at eight entries on the server (
liveDashFeedCap) and ships a Timeline that fits in one viewport without scrolling.
A feed that must not lose entries is not an SSE feed. See the decision table below.
Keyed row updates without rebuilding unrelated regions
A data-island slot swap replaces the slot's innerHTML wholesale.
That is the entire update granularity: the slot, not the row. To make
swaps cheap and DOM-stable:
- Give each row a stable
id(DataTable'sRow.ID) so the browser's HTML parser reuses nodes when the new HTML is byte-similar to the old. - Keep the slot boundary narrow. The dashboard pushes three independent islands (stats, feed, jobs) so a metric tick does not re-encode the jobs table. One monolithic island would re-render everything per tick.
- Do not push unrelated chrome. The PageHeader, the operator console,
and the "How this is wired" prose are SSR-only. They never appear in
a
PushUpdatepayload.
Separate high-frequency visuals from polite aria-live
This is the most common composition mistake. Numeric StatCards that
update multiple times per second are visually rich and aurally hostile:
an aria-live region announces every change, flooding screen readers.
The rule:
- High-frequency numbers → NOT
aria-live. They are background visuals. - Activity feed, toasts, status transitions →
role="status"(implicitaria-live=polite) orrole="alert"for urgent transitions. - The dashboard's StatCard grid has no ARIA live attribute on purpose; the Timeline feed carries the polite lane.
Pause work for hidden and background tabs
A tab moved to the background is throttled by the browser: rAF callbacks slow to ~1Hz, IntersectionObservers stop firing, smooth scrolling freezes. Background work that ignores this wastes battery and CPU.
The carousel module is the canonical example: it listens for
visibilitychange and stops its autorotate timer when
document.visibilityState === "hidden", restarting on return. SSE
itself stays connected in the background (the OS keeps the network
socket alive), but you should not spin rAF animations or poll loops
against a hidden tab. Gate them on visibilitychange.
Dispose subscriptions on navigation
The runtime's animate and computed modules subscribe element closures
into G._signals[name].listeners on wire. The same modules splice those
closures back out on gofastr:navigate once the element is detached.
Without this, every SPA page swap would leak closures (and the detached
DOM nodes they close over) for the lifetime of the session.
The lesson for app-owned islands: if you add a MutationObserver, an
IntersectionObserver, or a manual addEventListener to a region that
can be navigated away from, disconnect it on gofastr:navigate. The
existing modules (toc, scrollspy, networkretrybanner) show the
shape: track active observers in a Set, walk it on navigate, disconnect
each, then re-scan after the new DOM settles.
Delivery semantics
The delivery lane you choose is the contract you are promising. SSE is not a durable ledger. It is best-effort push for "the server's current state changed; here is the new view."
Pull (poll) vs push (SSE)
Pick the lane by who needs to know when:
- Poll when the dashboard shows the server's current value on a
cadence.
data-cui-poll="5s" data-cui-poll-src="/islands/stats"re-fetches the region on the interval; any replica answers from the DB. No fanout, no held connection, no per-session push channel. This is the recommended tier for a status pill, a counter, a queue-depth gauge, or any surface where a few seconds of staleness is fine and the user did not ask for the update. - Push with SSE when the server needs to initiate the update or the cadence is sub-second. Presence rosters and collaborative surfaces need the connection itself, either because the connection IS part of the truth (presence) or because polling at the cadence the product needs would hammer the server. SSE costs a held connection per page and (across replicas) a fanout.
The demo on this page uses SSE because the ticker fires faster than a sensible poll and the doc needs to show the push lane. A real dashboard that ticks once every 5–30 seconds should poll. See Reactivity model for the full ladder.
Decision table
| Job | Source of truth | Lane | Reconnect behavior | Duplicate handling |
|---|---|---|---|---|
| Latest-state dashboard (throughput, gauge, badge count) | Server's current value | SSE with default ?buffer=N (oldest-drop) | Refetch current island HTML; intermediate frames are gone. A future frame heals the miss. | Idempotent: same value applied twice is a no-op. |
| Event feed / status timeline | Durable rows in your DB (the feed is a projection) | SSE for invalidation; the rows are source of truth | Authoritative refresh: refetch the rendered feed island, OR refetch the page slice and re-render. The runtime does not replay missed frames. | Idempotent if your feed renderer dedups by event id. Plan for duplicates anyway: at-least-once under fanout. |
| Every-event-matters (billing, audit, workflow transition, ordered processing) | Durable queue or transactional outbox | SSE NOT used for delivery. SSE may be used to invalidate a view ("an event you care about arrived"), but the durable lane owns the side effect. | The outbox relay redelivers undelivered rows on restart. SSE reconnect changes nothing about durable delivery. | Consumers must be idempotent, keyed on (consumer, Event.ID). |
If the question is "did this exact event reach the browser?", the answer is "do not use SSE to answer that." Use RPC for the initiating request's confirmation and a durable queue for any side effect.
Default oldest-drop versus ?slow=block
The SSE broker gives each subscriber a bounded buffer. When a subscriber cannot keep up, the default is oldest-drop: the oldest queued frame is dropped so the subscriber always sees the most recent state. This is correct for dashboards (a stale gauge is worse than a missed frame) and presence rosters (a stale roster is a bug).
?slow=block (or the X-SSE-Slow: block header) opts into the opposite:
the broker waits for buffer space before moving on. Use it only when a
slow subscriber is allowed to backpressure the emitter, never for
fanout from a hot path. The default keeps emitters non-blocking.
The broker must enable this before a request can select it:
stream.NewSSEBroker(stream.SSEBrokerConfig{ Topic: "dashboard", AllowClientSlowMode: true, // else ?slow=block is ignored BlockTimeout: 2 * time.Second, // bound on one blocked send (default 5s) MaxSubscribers: 500, // reject past the global cap (default unlimited) MaxSeatsPerPrincipal: 16, // per-caller seat cap (default 16; -1 lifts it) SeatOverflow: stream.SeatOverflowEvictOldest, // or Refuse (default) Principal: principalFromRequest, // nil = no eviction (see below) })
MaxSeatsPerPrincipal bounds one caller's concurrent streams (16 by
default, and the zero-value config gets it): each seat is a goroutine
plus a buffered channel, and without the bound a single low-privilege
user can exhaust the process's goroutines and file descriptors for
everyone. The caller is whatever Principal resolves; with no
Principal, every anonymous stream shares ONE seat bucket, so raise the
cap (or set Principal) on a public stream that legitimately serves
more than 16 anonymous viewers. Past the cap, SeatOverflowRefuse
(the default) answers 429 at connect, and SeatOverflowEvictOldest
closes that caller's oldest stream and seats the new one — the
multi-tab-friendly policy for clients that reconnect faster than a
half-open connection's seat is reclaimed. An evicted or replaced
stream's handler returns even when it is parked in a write to a peer
that stopped reading: the broker sets a past write deadline on its
response, so the cap bounds goroutines and connections without a
server WriteTimeout. The same shape bounds
core/mcp's SSE notification stream (SetSSESeatCap /
SetSSESeatOverflow there).
Principal decides who may evict a subscriber id. A ?subscriber_id
is a label a client chooses; it is not an identity. With no Principal,
the broker evicts nothing. A reconnect with the same id does not drop
the previous entry, because the broker cannot tell whether the same
caller came back. Set Principal to a function returning something the
caller cannot choose and another caller cannot guess (a session user id
is the usual answer), and a reconnect with the same id replaces only its
own entry. The old default keyed on RemoteAddr's host, which behind any
reverse proxy is the proxy for every request, so every subscriber
collapsed into one principal and ?subscriber_id=<victim> dropped a
stranger's stream. Evicting nothing costs nothing: delivery is a
broadcast, and a dropped connection already unregisters itself.
Block mode is off by default because deliver walks subscribers
sequentially on the publisher's goroutine: a block-mode subscriber that
stops reading stalls every other subscriber and whatever called
Publish, usually a request handler. On a public endpoint that is an
unauthenticated denial of service, so the decision belongs to the
developer who knows whether the endpoint is trusted, not to the caller.
BlockTimeout bounds the stall even when enabled.
Fanout across replicas
By default the SSE bus and the island manager are per-process: a push
on replica A reaches only sessions connected to A. Attaching a fanout
(framework.WithFanout) bridges the real-time lane across replicas, so
an SSE subscriber on B receives a push emitted on A. The fanout is
also lossy: it is a broadcast, not a log. A message published while
a replica's listener is reconnecting is gone.
Under fanout, every On/Subscribe handler fires on every replica
for every event. That is right for UI push and wrong for side effects.
A handler that emits a new event in response must gate on
event.IsRemote(ctx) to avoid every replica deriving its own copy. Side
effects belong on the durable lane (framework.WithOutboxConsumer).
See Events and SSE for the full semantics.
Reconnect, duplicates, sequence tokens
SSE reconnect is built into the runtime's sse module: on transport
error it closes, waits 3s, and reconnects. window.__gofastr.sseStatus
mutates in place ({connected, lastEventAt, retryCount}) and a
gofastr:sse-status CustomEvent fires on every transition. The
connection-health banner listens for the event and re-probes its health
endpoint on reconnect.
What SSE reconnect does not do:
- Replay missed frames. The new connection sees only frames emitted after it opens.
- Refetch island HTML. Your islands show whatever the last successful push left there. If you want them to reconcile, add a listener:
document.addEventListener('gofastr:sse-status', (ev) => { if (!ev.detail.connected) return; // The stream just reconnected. Refetch the current island HTML. fetch("/__site/livedash/refresh?island=stats") .then(r => r.text()) .then(html => { const el = document.querySelector('[data-island="livedash-stats"]'); if (el) el.innerHTML = html; }); });
The /__site/livedash/refresh endpoint is exactly this reconcile
surface; the dashboard ships it but does not wire the listener, so the
doc can show it in isolation.
- Deduplicate. If the emitter pushed the same value twice and the broker delivered both, the runtime applies both. Idempotency is the island renderer's job, and the easiest idempotency is "render the latest state," which is what the dashboard does.
For an every-event-matters surface, do not try to bolt sequence numbers
onto SSE. Use the transactional outbox: each row has a stable id,
consumers deduplicate on (consumer, Event.ID), and the relay guarantees
at-least-once delivery. SSE's role shrinks to "wake the view; reconcile
from the durable source."
Stale-update rejection
When a durable feed (entity events, queue transitions) drives a dashboard, a late-arriving event from a previous window can overwrite a newer view. Two patterns handle this:
- Version tokens. A sortable column on the source row
(
created_at,version,id) ships in the SSE payload. The renderer keeps the highest-seen token per row and drops frames whose token is older. This is whatui.SortableList'sdata-hui-sortable-version+ 409 reconciliation path does for reorders. - Authoritative refetch. On any ambiguous signal (reconnect, a duplicate, an out-of-order id), refetch the rendered island HTML from the source of truth and replace the slot wholesale. Simpler than in-client dedup, at the cost of one request.
The dashboard uses pattern 2 (the refresh endpoint). Pattern 1 is the right shape when the renderer must converge without a round-trip.
Tenant isolation
The demo uses ONE fixed topic (live-dashboard-demo) because the demo
has no authenticated identity. Every viewer is meant to see the same
metric ticks. A fixed topic is wrong for a multi-tenant app. Two
different tenants' users would join the same topic and pushAll would
broadcast identical HTML to all of them, with no isolation, regardless of
any SetAuthorizeTopic gate you add. A gate only checks whether a
session may join a topic; it does not invent a per-tenant topic. The topic
itself must be tenant-qualified.
The right shape for a multi-tenant dashboard:
- Derive the topic server-side from the authenticated identity. Never trust a client-supplied topic. Read the tenant id from the request-context user and build the topic from it:
func dashTopicFor(ctx context.Context) (string, error) { tenantID, err := identity.TenantFrom(ctx) if err != nil { return "", err } return "tenant:" + tenantID + ":dashboard", nil }
The screen declares the derived topic, exactly as the demo declares its fixed one. The wiring does not change, only the topic value does:
func (s *DashScreen) PresenceTopics(ctx context.Context) []string { topic, err := dashTopicFor(ctx) if err != nil { return nil } return []string{topic} }
The host signs the list for the viewer it rendered the page for, so a
client cannot swap in tenant:someone-else:dashboard or reuse another
user's list. A topic the client names itself (?presence=) is refused
while no hook is installed.
- Re-check the topic against the identity while the stream is open.
A declared topic is decided when the page renders. When a user can
leave a tenant while a dashboard stays open, install
Manager.SetAuthorizeTopic: it then answers for every topic, declared ones too, at SSE-connect time and on each heartbeat, and a viewer who no longer belongs to the tenant leaves the topic:
host.Islands.SetAuthorizeTopic(func(ctx context.Context, topic string) bool { want, err := dashTopicFor(ctx) if err != nil { return false } return topic == want })
Rejected topics are dropped silently. The client is simply never subscribed, so an unauthorized viewer can't distinguish a forbidden topic from a nonexistent one.
- Render per-tenant state. The snapshot must come from the tenant-scoped source of truth, not the package-global demo state. Each tick iterates the tenants with active viewers and pushes only that tenant's snapshot to its sessions:
for _, topic := range host.Islands.ActiveTopics() { tenantID, ok := tenantFromTopic(topic) // "tenant:<id>:dashboard" → "<id>" if !ok { continue } snap := repo.Snapshot(ctx, tenantID) pushTenantIslands(host.Islands, topic, snap) }
PresenceSessions(topic) already returns only sessions that joined
the topic, so a push to tenant:X:dashboard reaches only tenant X's
viewers, with no cross-tenant bleed.
- Apply the same derivation on the refresh endpoint.
/__site/livedash/refreshmust re-derive the tenant from the authenticated request and render that tenant's snapshot. Never let an?island=or?tenant=query param select state. Otherwise a dropped SSE frame lets one tenant reconcile another tenant's view by hand-crafting the URL. The?island=param may select which island to render, but whose data feeds it is identity-derived.
The demo intentionally skips all four steps because it is single-tenant by design. Copying the demo's fixed topic into an authed app is the most common isolation bug. A hook alone does not fix it.
Performance evidence
The numbers below are measured by the existing framework benchmarks,
not new claims. Re-run with go test -run=^$ -bench='BenchmarkSSE|BenchmarkEventBus' -benchmem ./framework/.
| Benchmark | What it measures | Result |
|---|---|---|
BenchmarkSSE_BackpressureDropRate | Drop rate under a slow subscriber paired with a fast emitter through the production core/stream.SSEBroker (?buffer=128) | 5000 fast-published events → 133 delivered, 4867 dropped (drop rate 0.973). The intended contract: bounded, non-blocking, latest-state retention. |
BenchmarkSSEWriter_Write | Per-frame encode + write cost through the production SSE writer | The per-event cost the EventStream handler pays on every outgoing frame. |
BenchmarkT9_IslandRPC_Concurrency | Island RPC tail latency at worker=64 concurrency | p50 11µs, p90 26µs, p99 0.42ms, p999 20ms (v0.90.0, SQLite). The p99 sits below the 10ms target. |
BenchmarkT9_UIHostPageRender | UI-host full-page SSR cost | / at 142µs / 1.1k allocs for a 15.3KB response (v0.90.0). |
What this tells you about a dashboard:
- A dashboard that pushes one island per tick at 1–10Hz is comfortably inside the broker's budget. Single-replica fanout to a few hundred sessions is not a measured ceiling; it depends on the broker's buffer and your payload size, not on framework overhead.
- Drop rate spikes only when a subscriber cannot drain faster than the emitter pushes, which is the intended backpressure path. If a subscriber reports dropped frames, that subscriber is the bottleneck; either reduce the push rate, narrow the payload, or accept that the subscriber sees latest-state rather than every frame.
- Page SSR cost dominates first paint, not the live updates. A dashboard that takes 4ms to SSR will feel instant; one that takes 40ms has a render cost problem, not a delivery cost problem.
Unmeasured: browser hydration, layout cost of innerHTML swaps, and
the actual fanout ceiling for your payload size across your topology.
Treat those as unmeasured. Profile them in your own app before
promising a number.
Wiring checklist
- Pick the lane per region. Metrics → SSE latest-state. Feed → SSE for invalidation, durable source for truth. Side effects → outbox.
- Pick the slot boundary. One island per region that changes independently; do not bundle unrelated state into one push.
- Pick the aria-live policy. Polite for feeds and status transitions; nothing for high-frequency numbers.
- Pick the reconnect policy. Either accept "latest-state wins"
(default) or wire a
gofastr:sse-statuslistener that refetches the island HTML on reconnect. - Pick the scope. Use
PresenceSessions(topic)for push targets. For multi-tenant apps, declare a tenant-qualified topic derived from the authenticated identity (see Tenant isolation: a fixed topic is wrong for multi-tenant, andSetAuthorizeTopicalone does not fix it). - Stop the ticker on shutdown.
app.OnStopcancels the ticker context so SIGTERM drains cleanly.
See also
- UI capability map places live dashboards in the wider state-and-delivery model.
- Signal store for
store.Computedand the typed projection primitive the status pill uses. - Events and SSE for the broker's backpressure modes, fanout semantics, and the transactional outbox.
- Presence: the same push lane used for "who's here"
rosters. The dashboard reuses
PresenceSessions(topic)as its delivery target list. - Interactive patterns for
data-cui-signal-incand the rest of the in-page mutation vocabulary. - Horizontal scaling for the multi-replica delivery story.
- Benchmarks and Performance results for the measured limits cited above.
Common mistakes
- Treating SSE as a durable log. It is best-effort push. Use the transactional outbox for any side effect that must not be lost.
- Putting high-frequency numbers in an
aria-liveregion. They flood assistive tech. The polite lane is for feeds and status transitions, not gauges. - One monolithic island. Pushing the whole dashboard body per tick re-renders unrelated regions. Split into per-region islands so a metric tick does not re-encode the jobs table.
- Buffering the feed client-side. Trim server-side, before re-render. The wire payload stays small and the client never has to reason about eviction policy.
- Pushing to every session. Address pushes to
PresenceSessions(topic)so only viewers who joined the topic receive them. A fixed topic is wrong for multi-tenant apps. Declare a tenant-qualified topic derived from the authenticated identity (see Tenant isolation). - SPA navigation won't re-thread the topic. The SSE topic is read
from the page's
<meta name="gofastr-sse">at initial load; a partial-nav (an in-site link click) does not rewrite it, so a user who arrives at the dashboard via SPA nav sees the SSR paint but the stream joins the topic the document was loaded with, if any. What SPA nav DOES do now: the stream itself is on demand — it opens only while the page holds a push target (an island region, or the offline banner) and closes when the last one leaves, so navigating away from the dashboard unsubscribes the viewer, and navigating back re-joins the topic the meta still carries. A dashboard reached by SPA nav whose initial load was a DIFFERENT page joins no topic at all; full-load the dashboard URL so the meta is rendered with its topic. The command-palette entry for the dashboard uses normal SPA nav and so exhibits this. Open the page directly to see live updates. See Presence, where the same limitation applies to the roster demo ("Re-threading presence on SPA navigation"). - Stopping the ticker on shutdown. A goroutine that pushes into a
closed manager on SIGTERM is a leak. Register it with
app.OnStop.