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:

RegionPrimitiveWhat it proves
Metric StatCards (throughput, workers, queue depth, p99)ui.StatCard inside a data-island slotServer 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 feedui.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 tableui.DataTable (rows keyed by Row.ID) in a data-island slotA keyed changing collection. Successive pushes produce HTML that differs only on rows that actually changed.
Operational status pillstore.Computed bound via Slice.BindA 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 bannerui.NetworkRetryBannerFollows 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 refreshGET /__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 deliveryhost.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.

9 lines
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.

6 lines
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=N with 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's Row.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 PushUpdate payload.

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" (implicit aria-live=polite) or role="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

JobSource of truthLaneReconnect behaviorDuplicate handling
Latest-state dashboard (throughput, gauge, badge count)Server's current valueSSE 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 timelineDurable rows in your DB (the feed is a projection)SSE for invalidation; the rows are source of truthAuthoritative 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 outboxSSE 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:

9 lines
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:
10 lines
  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:

  1. 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 what ui.SortableList's data-hui-sortable-version + 409 reconciliation path does for reorders.
  2. 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:

  1. 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:
7 lines
   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:

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

  1. 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:
7 lines
   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.

  1. 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:
6 lines
   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.

  1. Apply the same derivation on the refresh endpoint. /__site/livedash/refresh must 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/.

BenchmarkWhat it measuresResult
BenchmarkSSE_BackpressureDropRateDrop 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_WritePer-frame encode + write cost through the production SSE writerThe per-event cost the EventStream handler pays on every outgoing frame.
BenchmarkT9_IslandRPC_ConcurrencyIsland RPC tail latency at worker=64 concurrencyp50 11µs, p90 26µs, p99 0.42ms, p999 20ms (v0.90.0, SQLite). The p99 sits below the 10ms target.
BenchmarkT9_UIHostPageRenderUI-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

  1. Pick the lane per region. Metrics → SSE latest-state. Feed → SSE for invalidation, durable source for truth. Side effects → outbox.
  2. Pick the slot boundary. One island per region that changes independently; do not bundle unrelated state into one push.
  3. Pick the aria-live policy. Polite for feeds and status transitions; nothing for high-frequency numbers.
  4. Pick the reconnect policy. Either accept "latest-state wins" (default) or wire a gofastr:sse-status listener that refetches the island HTML on reconnect.
  5. 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, and SetAuthorizeTopic alone does not fix it).
  6. Stop the ticker on shutdown. app.OnStop cancels 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.Computed and 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-inc and 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-live region. 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.