A Resilient Browser Client
The canonical wiring for a client that survives flaky links — keep-alive, half-open detection, truthful reconnect UX, unknown-outcome intents, teardown order, and chaos-testing your own app.
Every piece of link resilience is individually documented; this page is the assembly — the wiring a production browser client (a game, a live board, anything realtime) should ship, and what each part is for. It exists because consumers otherwise re-derive it from library source, one incident at a time.
The canonical wiring
Section titled “The canonical wiring”import { connectChannel, wsCarrier } from "@nice-code/action";import { connectRealm, realmConnection, isUnknownOutcomeRejection } from "@nice-code/realm";
let session: { matchId: string } | undefined; // your mutable dial context
const connector = connectChannel(runtime, matchChannel, { peer, storage, transports: [ { // Return null when there's no valid endpoint (torn-down session): the redial // loop PARKS instead of dialing garbage — teardown order stops mattering. carrier: wsCarrier(() => session == null ? null : { url: `${WS_BASE}?matchId=${session.matchId}` }, ), }, ],
// Auto-redial on unexpected drops (backoff + jitter). Default: already on for a // realm-carrying link — shown here for the guide. keepLinkAlive: true,
// Half-open detection (browser offline, NAT death: the socket LOOKS open, frames // vanish). Idle ⇒ wire ping; no reply ⇒ local close ⇒ the ordinary redial. Also on // by default with keepLinkAlive; the knobs are the point (≈ idleMs + pongTimeoutMs // worst-case detection, default 15s + 5s). linkKeepalive: { idleMs: 15_000, pongTimeoutMs: 5_000 },
// The observation surface: truthful reconnect UX + flaky-link telemetry. onLinkEvent: (e) => { if (e.type === "link_down") hud.linkDown(); if (e.type === "redial_scheduled") hud.retrying(e.attempt, e.delayMs); if (e.type === "link_up") hud.linkUp(e.downForMs); // undefined on the first connect },});
const realm = connectRealm(matchRealm, { avatar: { type: "player" }, connection: realmConnection(connector), onMutationRejected: (rejection) => toast.rejected(rejection), onDiagnostic: (e) => telemetry.count(`realm_${e.type}`), // see "who sees what" below});The first connect — when the server isn’t up yet
Section titled “The first connect — when the server isn’t up yet”The classic dev-loop race: you restart both processes, the frontend reloads in 200 ms, the backend takes two seconds, and the app’s very first dial hits a closed port. This is not a special case. A failed dial arms the same keep-alive backoff ladder that a mid-session drop does — including the first one, before any link has ever come up — so the app heals on its own with no reload:
redial_scheduled (attempt 1, in 758 ms)redial_scheduled (attempt 2, in 1613 ms)link_up ← the backend finished bootingWhat the library does for you, and what it leaves to you:
- It retries indefinitely. Each rung doubles — 1 s, 2 s, 4 s … capped at 30 s — and the actual wait is jittered into the top half of its rung, so a whole fleet doesn’t reconnect in lockstep. A server can always come back, so nothing gives up on its own: see deciding to stop.
connect()still rejects on that first failure. The rejection is the truthful answer to “am I connected right now?”; the retries continue behind it. If youawait connector.connect()at startup, catch it and render “connecting…” rather than treating it as fatal.- A pre-open dial failure is silent on the console. It is a failed dial, not an outage, and logging it would spam a line per backoff rung while a peer boots. Observe it through
onLinkEvent, neverconsole.error. (A failure after the socket opened still warns.) createRequestreturningnullparks instead. No endpoint means there is nothing to dial, so the ladder waits for a real one rather than churning — the teardown-order guarantee from the wiring above.- A permanently-rejected identity parks too. If the server rejects the handshake because this client’s verify key doesn’t match the one pinned for its identity (trust-on-first-use), retrying can never succeed — so the ladder parks instead of re-running a doomed handshake forever. The rejection is typed:
connect()rejects withidentity_pin_mismatch(branch on it witherr_wire_connect.isExact(e) && e.hasId("identity_pin_mismatch"); see recovering from a pin mismatch for what causes it and how to recover). keepLinkAlive: falseopts out of all of this: the link is dialed on dispatch only.
Joining vs reconnecting — one latched boolean
Section titled “Joining vs reconnecting — one latched boolean”status === "connecting" covers two very different UX moments. hasBeenLive (latched on the first live, never reset for the handle’s lifetime) tells them apart:
realm.subscribeStatus((status, { hasBeenLive }) => { if (status === "live") hud.live(); else if (status === "connecting" && hasBeenLive) hud.reconnecting(); // warning banner else if (status === "connecting") hud.joining(); // first-join spinner else hud.left();});A “retry now” button belongs on the reconnect banner — bind it to the connector, which resolves when the fresh link’s handshake lands:
retryButton.onclick = () => void connector.reconnectLink();Status flips only on evidence of an outage
Section titled “Status flips only on evidence of an outage”A serverless host (a Cloudflare Durable Object) hibernates on every gameplay lull and wakes on the next frame. The wake heals in one round-trip on a socket that never dropped — so it must be invisible. It is: the wake re-hello is probe-flavored and keeps status === "live" throughout, exactly like the idle staleness probe. status moves to "connecting" only on evidence — a link the transport reported down, or a probe/re-hello that goes unanswered past probeReplyTimeoutMs (then probe_escalated fires and the connection re-dials). It never flips for a protocol chore. So folding status into a “reconnecting…” banner does not flap it on every wake — a real outage is the only thing that lights it.
What a wake looks like on the wire now: hello → (re-own) → answer → your rebased writes. Your in-flight writes park for that one round-trip instead of streaming at a momentarily-empty server (no reject burst), and any transient region you own is re-created before your resent intents run — see presence.
Deciding to stop trying
Section titled “Deciding to stop trying”The library never gives up on an outage, because it cannot know whether your server is rebooting or decommissioned. (The two non-outages above — no endpoint to dial, a pin-rejected identity — park by themselves; retrying can’t fix those.) A bounded policy — “after N attempts, tell the user we’re offline and stop burning battery” — is the app’s to own, and redial_scheduled carries the counter to build it from:
const connector = connectChannel(runtime, matchChannel, { // …transports, storage… onLinkEvent: (e) => { if (e.type === "link_up") hud.online(); if (e.type === "redial_scheduled") { hud.retrying(e.attempt, e.delayMs); // "reconnecting… (attempt 3, in 4.1 s)" if (e.attempt >= 8) { connector.releaseLink(); // stop for good — no phantom `link_down` hud.offline(); // the button below revives it } } },});
reconnectButton.onclick = () => void connector.connect();| Lever | Effect |
|---|---|
connector.reconnectLink() | Retry now — tears down and re-dials immediately, skipping the remaining backoff wait. Awaitable; rejects if that dial fails (the ladder carries on). |
connector.releaseLink() | Stop trying. Suppresses the redial and releases the socket. An app-initiated release is not an outage, so it emits no link_down. Not terminal — a later connect() revives the connector. |
keepLinkAlive: false | Never auto-redial at all; dial on dispatch only. |
connector.dispose() | Permanent teardown of the connector. |
Bound the attempts, not the elapsed time: with the 30 s cap, eight attempts is roughly a minute and a half of patient retrying — long enough to ride out a deploy, short enough that a laptop that closed its lid doesn’t retry until the battery dies.
Who sees what: onLinkEvent vs onDiagnostic
Section titled “Who sees what: onLinkEvent vs onDiagnostic”A drop that heals by redial is silent at the sync layer — no sync was lost, so onDiagnostic correctly reports nothing. The division of labor:
| Event | Where | Meaning |
|---|---|---|
link_down / redial_scheduled / link_up | connector onLinkEvent | Transport churn — reconnect UX, “how flaky are my users” telemetry |
link_redialed (with downForMs) | realm onDiagnostic | The ONE transport event forwarded to realm telemetry — count churn without a second wiring point |
resync, gap_dropped, frame_error, probe_escalated, late_reject, reown_missing | realm onDiagnostic | Sync-layer health — a healthy realm emits none, whatever the transport does |
A resync with reason: "write_target_missing" is the self-heal for a structural divergence: the server rejected a write whose target it no longer had (a delete/reset broadcast that never reached this client). The client re-reads and re-owns in one round-trip instead of looping on doomed writes — so a single such resync is recovery working, not a problem; a stream of them means a broadcast is being lost upstream. A late_reject (a reject for a frame already settled) is harmless but is the fingerprint of a divergence window worth logging.
A reown_missing { path } is the tripwire behind that self-heal: it fires only when a recovery ran but did not re-create a transient entry you own — your assertOnAttach produced no write for path (there is none, or the parent is genuinely gone). The library escalates once automatically (a forced full-hydrate, the in-place equivalent of a page reload); a second reown_missing for the same path means even that did not bring it back, so treat the entry as permanently gone (a pruned record, a removed peer) and reflect that in your UI rather than retrying. If you silence write_target_missing in onMutationRejected (recommended — it is benign wake noise once recovery is wired), reown_missing is your one trustworthy “this one did not heal” signal, so wire it.
Owning transient state across a wake: assertOnAttach
Section titled “Owning transient state across a wake: assertOnAttach”A transient region (transient: ["aim"]) is never logged or snapshotted, so a same-epoch Durable Object hibernation wake reloads it empty. Anything you own there — a cursor row, an aim reticle, “my side of the record” — must be re-declared on recovery. assertOnAttach is that hook: the library runs it after every hydrate and every replay resume on a transient-bearing realm (reconnect, wake rehello, and the periodic staleness probe all heal), and rides the re-own to the server ahead of any resent write or intent — so a fire that reads your aim runs against the re-owned entry, never the wiped region.
Write it as a guard — re-own only if the entry is actually gone:
connectRealm(matchRealm, { avatar: { type: "player" }, connection: realmConnection(handler), // Re-own my aim entry on recovery, preserving my real values from my own store. assertOnAttach: (draft) => { const me = identity.id; if (draft.players[me] == null || draft.aim[me] != null) return; // already present — nothing to do draft.aim[me] = { angle: myStore.angle, power: myStore.power, item: myStore.item }; }, onDiagnostic: (e) => { if (e.type === "reown_missing") forceHardReload(); }, // belt-and-suspenders});The draft the library hands you on a resume reflects the authoritative post-recovery state (the region refresh has already landed), so the guard observes a wake-wiped entry as genuinely missing and re-creates it, and a healthy resume observes it present and enqueues nothing — cheaper than an unconditional rewrite and equally correct. Read the values you re-own from your own source-of-truth store (above), not from draft: after a wipe the draft’s authoritative value is absent, so draft.aim[me]?.power ?? 0 would re-own at defaults.
When each event fires
Section titled “When each event fires”| Event | Fires | Not |
|---|---|---|
link_down | At the moment of close, for any real outage — network, server close, connector.debug.dropLink(). Independent of how long the socket’s own close callback takes (a real WebSocket’s onclose can lag by seconds). | Never for an app-initiated teardown (releaseLink() / clearTransportCache() / dispose()) — that is not an outage. Never coupled to the redial. |
redial_scheduled | When keep-alive actually arms attempt N. A debug.dropLink({ suppressRedialMs }) window suppresses only this — never the link_down above. | When keep-alive is off, parked (dial_unavailable), or suppressed. |
link_up { downForMs } | On re-establishment. downForMs measures the full outage (from link_down at close), not the redial duration. | downForMs is undefined only on the first-ever connect. |
Non-idempotent intents: the unknown-outcome catch
Section titled “Non-idempotent intents: the unknown-outcome catch”A frame_too_stale rejection means the outcome is unknown — the intent may have committed while the ack was lost. Never blindly re-issue; re-read state (it converges on the next replay/hydrate) and decide:
try { await realm.intents.fireShot({ angle, power });} catch (e) { if (isUnknownOutcomeRejection(e)) { // The shot may have landed — re-read state, don't re-fire. await refreshFromRealmState(); } else { showRejection(e); // a definite "no": a rules rejection (or transport failure) }}Teardown: leaving a session without a 3 a.m. bug
Section titled “Teardown: leaving a session without a 3 a.m. bug”Two levers, and with the null-returning createRequest above the ordering between them stops mattering:
function leaveMatch() { realm.detach(); // leave the realm (slot released, pending writes expired) session = undefined; // the dial context is gone — createRequest now returns null connector.releaseLink(); // stop the auto-redial and release the socket}releaseLink() is not terminal — the next connect()/dispatch revives the connector for a new session. (clearTransportCache() does the same suppression under a cache-flavored name; dispose() is the permanent teardown.)
Detection windows, and how the knobs compose
Section titled “Detection windows, and how the knobs compose”| Detector | Knob(s) | Worst-case notice | Covers |
|---|---|---|---|
| Clean close → auto-redial | keepLinkAlive | ~1 s (first backoff rung) | Any real socket close |
| Wire keepalive (half-open) | linkKeepalive: { idleMs, pongTimeoutMs } | ≈ idleMs + pongTimeoutMs (default ~20 s) | Every protocol on the link |
| Realm staleness probe + escalation | staleProbeAfterMs + probeReplyTimeoutMs | ≈ 35 s at defaults | Realm-layer belt-and-braces (also catches an unhealthy-but-open session) |
| Server presence grace | avatarDetachGraceMs | — | The server’s side of a blip: no roster flap while the client heals |
The wire keepalive is the primary half-open detector; keep the probe knobs at their defaults as the sync-layer backstop rather than tightening them to do transport work.
Close semantics — the heal is not coupled to the platform’s close event. A deliberate local close — a teardown (clearCache), the half-open keepalive detector, or debug.dropLink() — notifies the disconnect fan-out (the auto-redial trigger) immediately, so the heal runs on the fast ladder (~1 s). It does not wait for the carrier’s own platform close event, which a real WebSocket can deliver seconds late against a dead peer. The eventual platform event is deduped confirmation, so link_down fires once and link_up.downForMs measures the full outage. Practical upshot: a bare debug.dropLink({}) is a faithful fast-blip simulator (heals in ~1 s, like a real transient blip) — reach for { suppressRedialMs } only when you specifically want to hold a sustained outage open.
Chaos-testing your own app
Section titled “Chaos-testing your own app”Outage simulation from outside the library doesn’t work — a browser’s offline mode never closes the socket, and page-level WebSocket patching can’t gate the keep-alive redial (it heals straight through, as it should). Use the built-in seams:
// 1. Manual QA / e2e: the airplane-mode button — a REAL close (the server sees it),// with the redial suppressed for the window.await connector.debug.dropLink({ suppressRedialMs: 10_000 });
// 2. Integration tests: the library's own chaos transport.import { chaosLink } from "@nice-code/action/testing-transport";const link = chaosLink();// wire link.onConnection(...) to your acceptor; pass link.carrier to connectChannellink.chaos.hold(); // half-open: isOpen() true, frames vanish (browser-offline shape)link.chaos.releaseHeld(); // the network comes back, in orderlink.chaos.dropHeld(); // ...or lossy: the parked frames never arrivelink.chaos.closeLink(); // a real drop; chaos.dialCount counts the redialslink.chaos.refuseDials(true); // and keep the "server" unreachable
// 3. Unit-level WS control: inject the socket itself.wsCarrier(() => ({ url }), { createWebSocket: (url) => new MyControllableSocket(url) });@nice-code/realm/testing covers the rules/intents side; this transport surface is its link-layer counterpart.