Evaluate stateless architecture for Via's context model #19

Open
opened 2026-02-23 21:40:44 +00:00 by ryan · 0 comments
Owner

Summary

Via's current architecture stores application state in server memory (*Context per browser tab). This works but creates several architectural tensions that are worth re-evaluating as Via matures.

Problems to Address

1. Ephemeral state in a durable world

*Context holds the view function, signals, and action closures in memory. Server restart = total state loss. The only recovery is window.location.reload() (via.go L569-573). For simple pages this is acceptable, but for pages with significant UI state (filters, open drawers, form progress), it's disruptive.

Alternative: State lives in Datastar signals (client-side) and the database (server-side). The *Context becomes a thin SSE pipe. Server restart → SSE reconnects → client re-fetches via existing data-init / data-on-signal-patch bindings. No state loss.

2. Closures as the state graph

c.Action(func() { ... }) captures variables from the enclosing scope. The state graph — which actions share which variables — is implicit in closure captures. It can't be inspected, serialized, or debugged at runtime. As pages grow, this becomes the hardest kind of code to reason about.

Alternative: Actions become registered HTTP handlers that receive signal values as parameters. State comes from the request + database, not from captured closure variables. The state graph is explicit in function signatures.

3. Bidirectional signal synchronization

Signals exist in two places: client-side Datastar signals and server-side sync.Map. They sync browser→server on every action (injectSignals) and server→browser on every Sync()/SyncSignals(). This creates staleness windows and O(n) overhead per interaction.

Alternative: Signals live exclusively on the client. The server reads them from request parameters when needed and pushes changes via SyncSignals() when needed. One-way data flow in each direction, no server-side mirror.

4. Horizontal scaling ceiling

In-memory *Context objects require sticky sessions for multi-server deployments. Not an issue for single-server apps, but it's an architectural ceiling.

What should NOT change

  • c.Sync() re-rendering the full view + morph diffing — this is the right abstraction. The simplicity of "state changed → Sync() → done" outweighs the bandwidth cost, especially with Brotli compression on the SSE connection.
  • The SSE infrastructure — reconnection handling, keepalive, stale patch draining, beforeunload cleanup are all solid.
  • PubSub as a first-class concept — server-push is why persistent SSE matters.
  • The developer experiencec.Signal(), c.Action(), c.View(), c.Sync() is an intuitive API.

Possible Direction

Keep the ergonomic API surface but change the underlying model:

  • c.Signal() → creates a client-side-only Datastar signal (no server mirror)
  • c.Action() → registers an HTTP handler that receives signals as params (not a closure)
  • c.View() → defines the initial server render; c.Sync() re-renders and pushes the full view through the SSE pipe (unchanged)
  • Server-push to other users → signal-only push via SyncSignals(), letting client reactivity handle re-fetching

This preserves Via's strengths (simple API, full-view morph, SSE push) while eliminating the state management risks (ephemeral closures, bidirectional sync, scaling ceiling).

Context

This came out of planning persistent SSE for MUREP. Building MUREP's internal/sse package (stateless push pipe) highlighted the contrast with Via's stateful context model. Both approaches have merit — this issue is about evaluating whether Via should evolve toward the stateless model for its core while keeping the ergonomic API.

## Summary Via's current architecture stores application state in server memory (`*Context` per browser tab). This works but creates several architectural tensions that are worth re-evaluating as Via matures. ## Problems to Address ### 1. Ephemeral state in a durable world `*Context` holds the view function, signals, and action closures in memory. Server restart = total state loss. The only recovery is `window.location.reload()` (via.go L569-573). For simple pages this is acceptable, but for pages with significant UI state (filters, open drawers, form progress), it's disruptive. **Alternative:** State lives in Datastar signals (client-side) and the database (server-side). The `*Context` becomes a thin SSE pipe. Server restart → SSE reconnects → client re-fetches via existing `data-init` / `data-on-signal-patch` bindings. No state loss. ### 2. Closures as the state graph `c.Action(func() { ... })` captures variables from the enclosing scope. The state graph — which actions share which variables — is implicit in closure captures. It can't be inspected, serialized, or debugged at runtime. As pages grow, this becomes the hardest kind of code to reason about. **Alternative:** Actions become registered HTTP handlers that receive signal values as parameters. State comes from the request + database, not from captured closure variables. The state graph is explicit in function signatures. ### 3. Bidirectional signal synchronization Signals exist in two places: client-side Datastar signals and server-side `sync.Map`. They sync browser→server on every action (`injectSignals`) and server→browser on every `Sync()`/`SyncSignals()`. This creates staleness windows and O(n) overhead per interaction. **Alternative:** Signals live exclusively on the client. The server reads them from request parameters when needed and pushes changes via `SyncSignals()` when needed. One-way data flow in each direction, no server-side mirror. ### 4. Horizontal scaling ceiling In-memory `*Context` objects require sticky sessions for multi-server deployments. Not an issue for single-server apps, but it's an architectural ceiling. ## What should NOT change - **`c.Sync()` re-rendering the full view + morph diffing** — this is the right abstraction. The simplicity of "state changed → Sync() → done" outweighs the bandwidth cost, especially with Brotli compression on the SSE connection. - **The SSE infrastructure** — reconnection handling, keepalive, stale patch draining, `beforeunload` cleanup are all solid. - **PubSub as a first-class concept** — server-push is why persistent SSE matters. - **The developer experience** — `c.Signal()`, `c.Action()`, `c.View()`, `c.Sync()` is an intuitive API. ## Possible Direction Keep the ergonomic API surface but change the underlying model: - `c.Signal()` → creates a client-side-only Datastar signal (no server mirror) - `c.Action()` → registers an HTTP handler that receives signals as params (not a closure) - `c.View()` → defines the initial server render; `c.Sync()` re-renders and pushes the full view through the SSE pipe (unchanged) - Server-push to other users → signal-only push via `SyncSignals()`, letting client reactivity handle re-fetching This preserves Via's strengths (simple API, full-view morph, SSE push) while eliminating the state management risks (ephemeral closures, bidirectional sync, scaling ceiling). ## Context This came out of planning persistent SSE for MUREP. Building MUREP's `internal/sse` package (stateless push pipe) highlighted the contrast with Via's stateful context model. Both approaches have merit — this issue is about evaluating whether Via should evolve toward the stateless model for its core while keeping the ergonomic API.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ryan/via#19