Skip to content

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:

  1. The UI keeps rendering (no blank screen).
  2. Clicks during the outage are not lost.
  3. After reconnect, the UI converges on the authoritative server state without weird middle states.

Wun handles all three.

Reconnect at a glance

ServerClientUserServerClientUserSSE connectedSSE drops 🌐drop u8 from pendingpredicted == authoritativetap (intent u7)1POST /intent u72SSE :resolves-intent u73tap (intent u8) · taps continue4queue u8 in pendingUI dims to 55% opacity5POST u8 (network error)6SSE reconnect (backoff)7bootstrap {tree, state, screen-stack}8replay-pending! (POST u8 again)9cached response u8 (LRU hit) or fresh morph10

(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"> + #app dims 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 :replace at root with the current authoritative tree.
  • The current :state.
  • The current :screen-stack and :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_SECS to override). Below the typical 60s LB idle window so proxies see traffic and don’t reap the socket.
  • Client: tracks last-frame-ms on 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:

  1. Subsequent broadcasts are no-ops while stale (offer! still returns false, but we don’t churn).
  2. On the next successful offer, the server forces prior-tree = nil so the diff produces a single full :replace at root and ships the envelope with :resync? true.
  3. 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.

  • 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.