Reconnect & retry
TL;DR. The last tree stays rendered, dimmed, while the SSE stream is down. Clicks queue locally with their UUID; on reconnect they’re replayed and deduped against the server’s LRU. The user never sees a blank screen and never loses a click.
The contract
When the SSE stream drops, three things have to be true for the user not to lose their work:
- The UI keeps rendering (no blank screen).
- Clicks during the outage are not lost.
- After reconnect, the UI converges on the authoritative server state without weird middle states.
Wun handles all three.
Reconnect at a glance
(1) UI keeps rendering
The last display-tree stays bound to whatever each platform’s
view layer is rendering. We never blank it on disconnect. CSS /
Modifier alpha drops the visual opacity to ~55% so the user
knows clicks are queued, but the tree itself is intact:
- Web:
<body class="wun-offline">+#appdims via CSS. - iOS:
.opacity(0.55)on the WunView container. - Android:
Modifier.alpha(...)driven by the same status.
(2) Clicks during outage
Clicks fire intents through the regular dispatcher. The POST goes
out; if the network is down, the fetch promise rejects. The
pending queue in wun.web.intent-bus keeps the entry
regardless — we don’t drop it on POST failure.
When SSE reconnects:
(bus/replay-pending!)…re-POSTs every still-pending intent. Each carries its original UUID. Server-side dedup (LRU 1024) returns the cached response for any UUID it already processed (the disconnect happened after the server saw the POST), or runs the morph fresh if not.
Either way, the resulting :resolves-intent envelope drops the
matching pending entry on the client. No double-apply. No lost
clicks.
(3) Convergence
The bootstrap envelope sent on (re)connect contains:
- A
:replaceat root with the current authoritative tree. - The current
:state. - The current
:screen-stackand:presentations.
The client diffs against its prior tree (which still reflects the pre-disconnect state), applies the patches, and the optimistic predictions are layered on top via the pending queue. After the last replay-pending POST is resolved, the predicted state matches the authoritative state.
If the predicted state diverged (the user’s optimistic morph disagreed with the server’s), the next confirmed envelope ships patches that correct it. Brief flicker, then settled — the LiveView “stale ok” idiom.
Heartbeats and the watchdog
A silent SSE socket isn’t always dead — but it’s not always alive
either. NAT/load-balancer idle timeouts and HTTP/2 proxy quirks can
hide a closed TCP connection from EventSource for minutes. Wun
handles this on both sides:
- Server: emits a
{:type :ping :ts ms}envelope on a configurable interval (default 25s,WUN_HEARTBEAT_INTERVAL_SECSto override). Below the typical 60s LB idle window so proxies see traffic and don’t reap the socket. - Client: tracks
last-frame-mson every received frame (patches AND heartbeats). If 60s elapse without a frame, the web client closes the EventSource and reconnects manually. Native clients run the same watchdog.
The heartbeat envelope carries no patch — it’s purely a liveness signal. The client just resets its watchdog timer.
Backpressure resync
When the server’s per-conn outbound buffer fills (slow client,
saturated network), offer! returns false. Wun marks the conn
stale rather than tearing it down or silently dropping the
patch:
- Subsequent broadcasts are no-ops while stale (offer! still returns false, but we don’t churn).
- On the next successful offer, the server forces
prior-tree = nilso the diff produces a single full:replaceat root and ships the envelope with:resync? true. - The client clears its pending-intents queue (the queue is stale anyway; the bootstrap-shaped envelope re-derives the truth).
Net effect: a temporary buffer overflow costs you one full re-render of bandwidth, not a desynced UI.
Backoff
Native clients reconnect with exponential backoff:
1s, 2s, 4s, 8s, 16s, 30s (cap)Plus 0–500ms jitter so a thundering herd doesn’t reconnect in
lockstep. The browser’s EventSource does its own retry; we layer
the heartbeat watchdog on top so silently-dead sockets surface even
when EventSource thinks it’s still connected.
TTL
Pending intents older than 30s are dropped. If a reconnect takes longer than that, the user has effectively lost those clicks — but 30s is long enough that no normal disconnect drops anything, and short enough that a forgotten tab on a sleeping laptop doesn’t permanently shadow the queue.
Read next
- Head & hot-cache — the localStorage / UserDefaults snapshots that hydrate cold starts.
- Intents — UUIDs, the pending queue, and why morphs are pure.
- Wire format — what the bootstrap envelope looks like on (re)connect.