How a change flows¶
Let's trace a single interaction end to end. Same app as the tutorial: a slider feeds a filter, which feeds a chart, a KPI, and a table; an overview view depends only on the data.
The setup (initial load)¶
On the first GET /:
- The server creates a session — a fresh kernel graph + value registry — and sets a
golit_sessioncookie. - It runs the whole graph once (
initial_render), computing every node and rendering every view fragment. - It returns the full HTML page: the controls panel and every view, each as a
<section id="…">.
The page also opens one SSE connection (GET /events) that stays alive for server-pushed updates.
Path 1 — POST in (the common case)¶
The user drags the slider and releases. Alpine showed the live number during the drag (Tier 3); the release commits.
sequenceDiagram
participant B as Browser
participant S as Server (Litestar)
participant K as Kernel (Rust)
B->>S: POST /node/threshold (value=80)
S->>S: coerce → store threshold for this session
S->>K: dirty_subgraph(["threshold"])
K-->>S: [threshold, filtered, by_region, chart, kpi, table]
S->>S: execute dirty nodes (memo-skipping unchanged)
S-->>B: 200 OK — changed view fragments (hx-swap-oob)
B->>B: swap #chart, #kpi, #table in place
- POST in. HTMX posts the committed value to
POST /node/threshold. - Coerce & store. The widget coerces
"80"toint; the session stores it. - Schedule. The kernel returns the dirty subgraph in topological order.
- Execute with memo. Each node runs only if its input hash changed (see the reactive model).
overviewisn't in the subgraph at all — it's never considered. - Respond with fragments. Only the changed view fragments come back, each tagged
hx-swap-oob="true", so HTMX swaps them into#chart,#kpi,#tableby id — all in the single POST response. No SSE, no second round trip.
If a node memo-hits (its value didn't change), its view isn't in the response, so nothing swaps for it.
Path 2 — SSE out (server-initiated)¶
Some changes don't originate with the requesting client: a streaming source advances, a background job finishes, or a shared node is recomputed for everyone. There's no POST to ride — the server has to push.
sequenceDiagram
participant Job as Background task
participant PS as PubSub
participant SSE as SSE manager
participant K as Kernel
participant B as Browser
Job->>PS: publish Invalidation(node_id="feed", session=None)
PS->>SSE: deliver invalidation
SSE->>K: session.refresh("feed") (force recompute + downstream)
K-->>SSE: changed view fragments
SSE-->>B: event: node:chart\ndata: <svg…>
B->>B: HTMX sse-swap → #chart
- A node goes dirty server-side; an
Invalidationis published to thePubSub. - The SSE manager receives it and calls
session.refresh(node_id)for each affected session — forcing the node and recomputing downstream. - Each changed view fragment is emitted as a named event,
node:<id>. - HTMX's SSE extension swaps it into
#<id>by name — the same fragment-by-name contract as the POST path, just pushed.
The event name is the node identity. When chart goes dirty server-side, Golit emits event: node:chart and HTMX swaps #chart.
Why two channels¶
They match the actual direction of data:
- A user changing their own inputs is a request/response — POST handles it with zero persistent connection and the fragments come back inline.
- A change pushed to the user is unidirectional and server-initiated — SSE's exact shape.
Across a horizontally-scaled fleet, the SSE path is fed by Redis pub/sub: an invalidation is published once and every worker delivers it to the sessions it holds. That's the subject of Deployment & scaling.
What's on the wire¶
In both paths the payload is the final UI — an HTML/SVG fragment — not JSON for a client to reconcile. There's no client-side diffing framework and (for static charts) no charting runtime. The bytes you send are the bytes the browser displays.