Server-Side State Sync: Handed Off Entirely to Supabase Realtime
About 1113 wordsAbout 4 min
2026-07-30
GitHub repo
Within a session, many states change often:
- Agent Run queuing, running, completing, or failing
- Preview creating, starting, restarting, or ready
- Browser Test running, passing, or failing
If the frontend polls to stitch these together, several problems appear.
First, high request volume. Every UI refresh temporarily queries multiple statuses (run, preview, app test) and assembles them on the server. Multiple open tabs amplify the load.
Second, the frontend can see inconsistent state. On refresh, multi-tab use, or out-of-order network packets, the UI may overwrite newer state with older state and diverge from the server truth.
Third, the sync model is unclear. Supabase Realtime is better at pushing whole-row changes. If the frontend maintains many local events and merges by hand, UI state becomes another implicit state machine.
So we need a clear read model:
The server assembles state; the frontend only receives and replaces.
Core design
The sync mechanism has two layers:
- The command side updates real domain state
- The query side maintains the read model the UI needs
In other words: separate write path and read model.
Write path
Domain modules still update their own business state. For example:
- Agent Run updates execution status
- Daytona Runtime updates Preview status
- Browser Test updates test status
After those domain updates succeed, they call:
publishRuntimeUpdate(...)Its job is not to drive the UI directly, but to turn domain state into the unified view the frontend needs. If the update did not change any UI-relevant fields, version is not incremented. Lease renewal, for example, is internal coordination and should not force a UI refresh. That cuts meaningless pushes and stops the UI from thrashing on internal churn.
Read model
The frontend does not subscribe to many scattered events. It subscribes to one unified SessionRuntimeProjection, which includes:
runpreviewappTestversion
version is a monotonically increasing number. Whenever UI-related state changes, the server builds a new projection and bumps version.
On receiving a projection, the frontend does not partial-merge — it replaces wholesale. If the incoming version is older than current, discard it. That prevents out-of-order packets from overwriting newer state.
Transport layer
The projected read model travels different channels depending on the persistence backend.
Local development:
local file store → host SSE → Web UICloud deployment:
Supabase Postgres → Supabase Realtime → Web UICloud uses the session_runtime_projection table. The frontend subscribes to the row for the current session.
How the frontend consumes it
The frontend consumes runtime state via useSessionRuntime. On entering the page, it fetches initial state once:
GET /runtimeAfter that, no polling — all further changes arrive via Realtime. Simplified:
const channel = supabase
.channel(`runtime:${sessionId}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "session_runtime_projection",
filter: `session_id=eq.${sessionId}`,
},
(payload) => {
// applyProjectionIfNewer(payload)
},
);Core logic:
if incoming.version > current.version:
replace projection
else:
ignoreThe frontend does not need to know how each event merges. It only compares versions and accepts a full new state.
How Preview state is published
Preview’s true state comes from the Daytona runtime snapshot. After each successful CAS write on Daytona, UI-needed preview fields are projected from the snapshot and published into SessionRuntimeProjection.
// Publish UI projection only when derived preview fields change.
// Lease-only CAS no-ops should not trigger UI updates.
void publishPreviewFromSnapshot(saved, ownerId);An important constraint:
Publish a new projection only when UI-visible fields change.
Lease renewal, internal owner changes, and coordination-only fields must not push a new UI state.
The frontend cares whether Preview is ready, the PreviewURL, whether it is starting / restarting / failed, and any displayable error. It does not care about the lease holder, lease expiry, or whether a CAS was only a renewal. That isolates control-plane state from UI state.
Why not let the client merge
A seemingly simple approach: push preview.updated, run.updated, appTest.updated partial events and let the client merge. We did not — that moves complexity to the client.
The client would handle: out-of-order events, missing partial state, restoring initial state after refresh, multi-tab consistency, and cross-event dependencies. The frontend easily becomes an implicit state machine.
So the server assembles the full projection. The frontend receives the complete read model and applies it based on version. Sync semantics stay simple:
The server produces facts; the frontend shows the latest fact.
Deliberately not doing
No client-side merge of partial events
The client does not handle preview.updated-style patches — only full SessionRuntimeProjection.
No second state bus
We did not add Ably, Redis Pub/Sub, or another messaging system as a second UI state channel. State already lives in Postgres; Supabase Realtime can push table changes. Another bus raises consistency cost.
Do not mix chat tokens into the runtime channel
Agent streaming text still goes over Workflow SSE. The runtime projection only covers structured state: Preview, Run, Browser Test. Lifecycles and consumption patterns differ — keep them on separate channels.
Relation to sandbox scheduling
Sandbox scheduling converges real resources; realtime sync lets the UI see the converged result. Full chain:
Agent / Preview API
→ ensureDesiredState(desired)
→ Lease + observe/act
→ upsertRuntimeSnapshot(CAS)
→ publishRuntimeUpdate
→ SessionRuntimeProjection
→ Supabase Realtime / SSE
→ Web UIFirst half is the control plane:
ensureDesiredState
→ Lease
→ observe/act
→ CASIt solves: when multiple isolates operate the same sandbox, how to avoid duplicate creation and state overwrite.
Second half is read-model push:
publishRuntimeUpdate
→ SessionRuntimeProjection
→ RealtimeIt solves: how the frontend sees server runtime state promptly and consistently.
Splitting them keeps boundaries clear. Resource reconciliation does not drive the UI; the UI does not infer resource state. Reconciliation advances the real world toward desired state; the realtime projection turns current runtime into display state.
Summary
The core of this realtime sync design:
The server maintains a unified read model; the frontend subscribes to full projections and uses version to reject stale overwrites.
Concretely:
- Preview, Agent Run, and Browser Test project into one
SessionRuntimeProjection - On page entry, fetch initial state once; then receive updates via Realtime
- Each update replaces the whole projection — no client partial merge
versionrejects out-of-order or expired packets- Internal coordination like lease renewal does not refresh the UI
- Chat tokens stay on Workflow SSE, not the runtime channel
Result: the backend owns a consistent runtime view; the frontend only renders the latest version.
Related: Sandbox service state governance: a K8s-like declarative scheduling mechanism.
Changelog
68955-Translate all 14 blog posts to English for Plume i18n.onb7b2c-启用文章变更历史并升级主题配置。on4e4c9-更新博客内容与开发体验:置顶两篇工程化文章,Roadmate 加入演示视频,docs:dev 有缓存时跳过 RepoCard 拉取on758fd-调整三篇博客排序与元数据:去 tag、声明式置顶、Roadmate 标题突出三阶段on029e1-删除两篇博客:Roadmate 近场配对与 BabyLovable WorkflowAgenton142a9-优化博客标题与 Roadmate 阅读体验on7b6bb-Cursor: Apply local changes for cloud agenton
