Reactivity model
GoFastr ships four ways to make a page change after first paint. They form a ladder, cheapest first. Pick the lowest rung that fits the product behavior; reach for the next one only when the lower one cannot do the job.
| Rung | What it is | When it fits |
|---|---|---|
| Client signal | A string value held in the runtime signal store, mutated by data-cui-signal-set/inc/toggle. No server. | UI-only state. Tabs, toggles, theme, an open/closed panel. |
| RPC | A fetch to a server handler. The server reads from the DB and returns HTML or a value; the runtime swaps the region or sets a signal. | Anything the user triggered. The default for data. |
| Polling | The runtime re-fetches a region on an interval. | Passive freshness for a counter, a status, or a dashboard, without holding a connection. |
| SSE push | One long-lived /__gofastr/sse connection per page. The server pushes when state changes. | Semantics that need the connection: presence, collaborative editing, sub-second internal dashboards. |
The four rungs, in order
1. Client signals
A signal is a named string kept in the runtime signal store in the browser. Three click-time primitives mutate it:
| Attribute | Effect |
|---|---|
data-cui-signal-set="name:value" | Sets name to value |
data-cui-signal-inc="name[:delta]" | Increments name by delta (default 1; negative decrements) |
data-cui-signal-toggle="name" | Flips name between "true" and "false" |
They work anywhere on the page, inside a modal, drawer or popover too. On
a node that also carries data-cui-rpc, the signal mutation wins and no
request is sent.
Any element with data-cui-signal="<name>" re-renders when the value changes.
For typed, shared, SSR-seeded state, wrap signals with core-ui/store (see
Signal store).
No server involvement. No RPC. No page reload.
2. RPC
A user click or submit fires a fetch to a server handler. The handler reads
from the DB, renders fresh HTML (or returns a value), and the runtime swaps the
region or writes the value into a signal.
- Stateless. Any replica can answer; there is no in-process session object tied to the connection.
- Authoritative. The HTTP response is the source of truth for the mutation; the runtime reconciles against it.
- Covers every user-initiated data change: paginate, filter, create, update, delete, optimistic toggles with rollback.
- JSON POST bodies are decoded under strict top-level key rules: the
compiled server-action endpoint (
/__gofastr/action) refuses a body with duplicate or case-folded keys rather than decoding it last-wins. - A compiled server action runs its screen's Policy chain first (the same
chain that gates the page render) and answers 403 when it does not
Allow, so a caller the page refuses cannot run the page's actions. The
runtime sends the page path with each action (
pagein the body); when it resolves to the action's own screen, the policy reads that page's route params throughapp.MatchFromContext, as it does on a render. Any other path leaves the params empty, so write the policy to refuse on an empty param. The client picks the path, so the gate answers "may this caller see some page of this screen", never "may it act on this record": anything finer than the page gate (per record, per field) is the handler's job.
A mutation can also stale screens the user is not on: a create stales
every cached page of the list, an admin action stales /pricing. Name
them on the response with ui.InvalidateScreens(w, "/orders") and the
runtime drops those entries from its per-tab screen cache, so back-nav
re-fetches instead of showing the pre-mutation copy. This rides the
initiating RPC's response; it is part of this rung, not a fifth one. It
only reaches the tab that made the request; other tabs that need the
fresh value are the polling rung's job, and SSE is never a
cache-invalidation transport. For the current screen, use
data-cui-rpc-navigate (or return island HTML); eviction alone never
re-renders anything.
See Interactive patterns for the full attribute vocabulary and Optimistic UI for the mutation lifecycle.
The headless controls ride the rungs rather than adding to them. A
headless.Counter's value is a client signal (data-cui-signal /
data-cui-signal-inc, rung 1); a headless.Repeater's add and
remove are named submit buttons, and when the result swaps the
region the same buttons carry the RPC contract (rung 2); a
headless.StepWizard's submit is a plain form POST that becomes a
region update under its optional Island; a headless.Toast arriving
at runtime is the RPC response's header (X-Gofastr-Toast) read by
the feedback module, and the stack it lands in is mounted once by the
layout. The drag-time behaviour of NumberInput, Slider and
RangeSlider is local presentation: no request fires while a thumb
moves, and the form the inputs ride submits when the reader submits
it.
3. Polling
Polling re-fetches on an interval without holding a connection. It gives you freshness for passive surfaces, a status pill, a counter, a dashboard, with no fanout, no shared infrastructure, and no held connection.
Page level. Add two attributes to the region you want to refresh:
<div data-cui-poll="30s" data-cui-poll-src="/islands/orders/today"> …initial SSR content… </div>
data-cui-pollis a Go duration string (30s,5m,1h). Five seconds is the floor.data-cui-poll-srcis the URL the runtime fetches. The response body replaces the region'sinnerHTML, exactly like an RPC signal swap.- The interval is jittered so a fleet of tabs does not synchronize on a wall clock.
- The poller pauses while the tab is hidden and resumes when it returns.
- A failed fetch doubles the interval (capped at 5x the base, reset on the next success); the runtime keeps polling rather than spinning.
Widget level. A widget that wants to refresh its own signals on a cadence
calls Builder.Poll(interval):
panel := preset.FloatingPanel("ops-panel"). Slot("body", bodyComponent). Signal("queue_depth", widget.SignalFunc(readQueueDepth)). Poll(15 * time.Second). Build()
On each interval the runtime re-fetches the widget's /state endpoint and
re-applies the signals that changed. No SSE binding, no event name, no
per-widget push channel.
Why prefer polling. Any replica can answer the fetch from the DB. You do
not need WithFanout, you do not need sticky routing, and the page works the
same way on a single replica as it does behind a load balancer. For dashboards,
counters, and statuses this is the recommended tier.
Stopping a poll (terminal state). A poll whose job is done, whether a
job-status pill that reached completed or a batch that finished, should
not keep hitting the server forever. The server ends the cadence by
setting X-Gofastr-Poll-Stop: 1 (truthy: 1/true/yes/on) on the
terminal poll response. The runtime applies the response body (or, for
a widget, the final signal snapshot) and then tears down the timer, so
no further fetches land. The header is opt-in: a handler that forgets
it keeps polling, so a still-live poll cannot be terminated by
accident. For Builder.Poll widgets, declare the terminal condition
with Builder.PollTerminal(func() bool); the /state handler emits
the header automatically once the predicate returns true. It asks the
predicate before it reads the signals, so the stopping snapshot is never
older than the terminal answer, and it skips the header on a response
missing a signal whose read failed. A poll whose
region is swapped out entirely (an island swap that replaces the
data-cui-poll element, or a replacement carrying
data-cui-poll="off"/"0") is not re-wired either, so removing the
marker also ends the poll.
4. SSE push
The single /__gofastr/sse bus. One long-lived connection per page; the server
pushes frames when state changes.
SSE earns its cost when the product semantics need the connection: when the fact "this client is connected" is itself part of the truth (presence, the collaborative roster), or when updates land faster than a poll cycle could sensibly reflect (sub-second internal dashboards).
- Best-effort, not durable. A slow consumer drops old frames; a reconnect
does not replay them. Side effects that must not be lost go on the
transactional outbox or
battery/queue, never the SSE lane. - Multi-replica needs
WithFanoutso a push emitted on one replica reaches subscribers connected to another. Without it, push stays per-process. - Push only. The result of a user action arrives in the RPC response, never on the event stream.
- Streams outlive the request timeout. A response that flushes (every SSE
subscription does) flips the timeout writer into streaming mode so its 504
can no longer fire; the stream loop then ignores the request context's
DeadlineExceeded(middleware.Timeout firing on a still-connected subscriber, the original bug) while still unwinding promptly on a realcontext.Canceled(client disconnect). A heartbeat (default 15s,island.WithSSEHeartbeat) writes keepalive comments so proxies and load balancers don't idle-kill a live stream, and a bounded stream lifetime (default 5m,island.WithSSEStreamBound) reclaims a stream stranded by a peer the server can't observe as gone, even when its heartbeat writes keep succeeding into the kernel buffer. The pair is order-safe:NewManagerenforces bound > heartbeat at the end of construction, so a bound at or below the heartbeat restores the 5m default, and a heartbeat raised above that lifts the bound to heartbeat + 5m — a sub-heartbeat bound would otherwise reclaim every stream before its first keepalive write. The connection's read/write deadlines are NOT cleared (the reverted 217e8d06 did, which broke net/http's close-notify and stranded streams instead). See issue #159. - Concurrent streams are capped: 16 per session, 256 per client and
4096 per replica by default (
island.WithStreamCaps,island.WithPeerStreamCap). A client is the signed-in user, or an anonymous caller's address (an IPv6 address counts by its /64);Manager.ClientKeyreturns the key a request counts under. The per-client cap exists because a session costs one same-origin POST, so without it one client could mint sessions until it held every slot. Behind a reverse proxy, list it withuihost.WithTrustedProxies(orisland.WithTrustedProxieson a manager you build): the cap then reads theX-Forwarded-Forhop the proxy saw, walked from the right the way the rate limiter does. Without the list every anonymous visitor counts as the proxy's address and shares its 256 streams; the first refusal from a private or loopback peer with no proxy listed logs a warning saying so. The policy is reject-not-evict: an over-cap connect gets a429 Too Many RequestswithRetry-After, and existing streams are never dropped to make room.
See Events and SSE for the broker contract, Presence for the canonical push case, and Live dashboards for the decision between SSE push and polling.
WebSockets: when the app owns the protocol
The four rungs cover pages. Some surfaces need bidirectional frames the
page model does not shape: WebRTC signaling, multiplayer protocols,
anything where the client talks back on the same connection the server
pushes on. Those use core/stream WebSockets directly, and they carry
two obligations the framework does not assume for you. For WebRTC
signaling specifically, battery/rtc packages the
server side and the rtc runtime module (WebRTC rooms).
Ordering across reconnects. When a client reconnects, it needs a
snapshot of current state plus the events that follow it, and the two
must share one sequence so a delayed snapshot cannot resurrect state a
newer event replaced — a reconnect can otherwise replay a stale banner
over a live clear. stream.StateChannel (see
Core packages → stream) owns that contract
server-side: one snapshot per connect from one immutable read, events
with strictly increasing sequences, per-role filtering before
serialization. In the browser, load the ws runtime module
(__gofastr.loadModule('ws')) and fold every envelope through
__gofastr.createSequencedReducer(initial, apply), which applies only
sequence > appliedSequence and rejects everything older, whatever its
payload says. Call it as reduce(envelope, generation) with the
generation onMessage hands you: the first snapshot of a new
generation applies whatever its sequence, because a restarted server
or another replica numbers from its own start. Inside a generation the
strict rule holds. Generations only grow, so a late message from a
generation older than the one hydrated is refused. On the server, a reconnect's snapshot is numbered
above every event the channel already sent, so it never ties an event
the old connection applied.
Generations, not "it's back". A recovered socket does not mean the
protocol layered on it recovered: browser state can still hold a stale
offer, a failed peer, or a dedup entry that swallows the server's fresh
event as a duplicate. __gofastr.connectWebSocket(url, opts) gives
each reconnect a distinct generation and the hooks to resynchronize:
onGenerationStart (socket open), onHydrated (the application
applied the snapshot, via handle.hydrated(sequence)), and
onGenerationEnd with a bounded reason class (closed, error,
refused, stop) — never the raw close reason, and never a payload or
credential in any log. refused is a server that accepted the
handshake only to close with 4000 + a 4xx status (kept on
status.refused); it is final and not retried, where a 4000 + 5xx
close is an error and retried. Hooks are idempotent per generation. A new
generation invalidates only generation-bound work; which state survives
(a healthy peer, the session) is the application's call, and so is
marking the protocol caught-up with handle.resyncComplete().
WebSocket recovery does not prove WebRTC/media recovery. A live socket says nothing about a peer connection that failed or an offer that went stale; the application must renegotiate through these hooks and verify its own media returned. See Core packages → stream for the Go-side helpers.
Which rung fits
Three questions, in order:
- Did the user trigger it? Use an RPC. The server renders from the DB and the runtime swaps the region or sets a signal.
- Is it passive freshness: the server's current value, on a cadence, with no held connection? Poll. Any replica serves the fetch.
- Does the product need to know who is connected right now, or push under a
second? Use SSE. Wire
WithFanoutif you run more than one replica.
A fourth rule, kept negative: never deliver the result of a user action over SSE. SSE is push for other clients and for background changes. The action's own response comes back on the RPC that triggered it.
Statelessness contract
State lives in two places: the database (durable truth) and the client signal store (UI state). It does not live in server RAM tied to a connection.
The interactive layer is stateless. Any replica can serve any RPC, any poll fetch, and any SSE subscribe; the request reads from shared state (the DB, or a shared store like Redis) and renders the response. There is no in-process widget or island object the next request depends on.
Practical consequences:
- A poll fetched on replica B sees the same data a push from replica A would have shown.
- An RPC that lands on the replica the user has never talked to still renders the right thing, because it reconstructs from the URL, the session, and the DB.
- The only multi-replica requirement for push is
WithFanout, which bridges the SSE lane; the state itself was never per-replica.
If a surface cannot be reconstructed from shared state on every request, that is a design smell, not a topology choice. Redesign the handler to reconstruct, or move the state into the DB.
Sessions
The uihost session is an HMAC-signed token, not a server-side record. A token issued by one replica verifies on every other replica that shares the secret.
- One replica, no secret configured. The framework mints an ephemeral boot
secret at startup. Sessions work, but they roll over on restart because the
next boot mints a new one. Zero config; the trade-off is that after a deploy
each open page transparently re-mints its UI session (this is the
SSE/interaction transport session, not
battery/authlogin state; nobody is logged out). Recovery needs no user action: a page re-mints on its next render or navigation, and a purely idle tab re-mints from the SSE module itself (POST /__gofastr/session) once its stream reconnect starts failing. The idle re-mint only runs while the stream is open (the stream itself is on demand — it exists only while the page holds a push target); a page without one recovers on its next navigation, whose answer carries the fresh id. Ordinary and intercepted navigation apply thatX-Gofastr-Sessionrollover before checking response success, including 404s and policy refusals: the SSEmeta[name="gofastr-sse"]moves to the new token (no element id is needed) and all cached screens from the old identity are discarded. No application-side retry or cache reset is needed. Transport failures retry after 1.5–3 seconds with independent jitter, doubling to a cap of 30–60 seconds per attempt. A successful stream open resets the backoff. DOM insertions cannot bypass a pending retry, so a permanent failure does not hammer the stream and session endpoints every few seconds. A bfcache restore reconnects immediately: hiding the page cancels the timer and clears the pending-retry marker together. - Multi-replica. Set a shared secret so every replica verifies the same
tokens. Two ways to set it:
-
framework.WithSecret(secret)in code, or - theGOFASTR_SECRETenvironment variable. - Fanout without a configured secret fails at boot. A multi-replica
deployment that wired
WithFanoutbut forgot the secret refuses to start, with an error naming both. This is deliberate: silent token mismatch in production is worse than a loud boot failure.
Sticky sessions are not part of the contract. A token is portable; route the request to whichever replica the load balancer picks.
See also
- Interactive patterns: the full
data-cui-*vocabulary, including the RPC and signal primitives summarized above. - Widgets:
Builder.Polland the widget builder. - Presence: the canonical SSE push case.
- Live dashboards: choosing between polling and SSE for a live surface.
- Events and SSE: the broker contract and the durable outbox.
- Scaling: multi-replica delivery,
WithFanout, and the session-token checklist. - Runtime contract: the SSR / hydration / island / SSE
boundary and the
data-cui-*attribute reference.
Common mistakes
- Jumping to SSE for a dashboard. A counter or status pill that ticks every few seconds is a poll, not a push. SSE is the right answer when the connection itself is part of the truth or the cadence is sub-second.
- Delivering a user action over SSE. SSE is push for other clients. The result of the click the user just made comes back on the RPC response.
- Forgetting
GOFASTR_SECRETbefore adding a second replica. Sessions issued by one replica stop verifying on the other. The fanout-without-secret boot failure catches this for push; set the secret anyway, before you scale. - Holding widget or island state in process RAM. A request landing on a different replica cannot read it. Move the state to the DB or redesign the handler to reconstruct.
- Polling a path that mutates.
data-cui-poll-srcshould be a read. Polls fire on a timer; a poll that writes would write on every tab, on every interval, in every browser. - Treating a reconnected WebSocket as recovered application state.
The socket coming back says nothing about a stale offer, a failed
peer, or a dedup key that eats the server's next event. Key
generation-bound work to the reconnect generation and resynchronize
through the
wsmodule's hooks; verify media protocols returned on their own terms.