Skip to content

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.

client.ts
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 booting

What 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 you await 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, never console.error. (A failure after the socket opened still warns.)
  • createRequest returning null parks 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 with identity_pin_mismatch (branch on it with err_wire_connect.isExact(e) && e.hasId("identity_pin_mismatch"); see recovering from a pin mismatch for what causes it and how to recover).
  • keepLinkAlive: false opts 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.

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();
LeverEffect
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: falseNever 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:

EventWhereMeaning
link_down / redial_scheduled / link_upconnector onLinkEventTransport churn — reconnect UX, “how flaky are my users” telemetry
link_redialed (with downForMs)realm onDiagnosticThe ONE transport event forwarded to realm telemetry — count churn without a second wiring point
resync, gap_dropped, frame_error, probe_escalated, late_reject, reown_missingrealm onDiagnosticSync-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.

EventFiresNot
link_downAt 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_scheduledWhen 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”
DetectorKnob(s)Worst-case noticeCovers
Clean close → auto-redialkeepLinkAlive~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 + escalationstaleProbeAfterMs + probeReplyTimeoutMs≈ 35 s at defaultsRealm-layer belt-and-braces (also catches an unhealthy-but-open session)
Server presence graceavatarDetachGraceMsThe 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.

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 connectChannel
link.chaos.hold(); // half-open: isOpen() true, frames vanish (browser-offline shape)
link.chaos.releaseHeld(); // the network comes back, in order
link.chaos.dropHeld(); // ...or lossy: the parked frames never arrive
link.chaos.closeLink(); // a real drop; chaos.dialCount counts the redials
link.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.