WebSocket Reliability Protocol
Context
established WebSocket as the real-time transport. The initial useWebSocket implementation handled reconnection and chaos simulation, but lacked protocol-level reliability:
-
No event deduplication. When
duplicateWsEventschaos was enabled, the hook delivered both the original event and the duplicate, doubling every transaction in the feed and corrupting the balance display. -
No sequence number tracking. After a reconnect, the client had no way to know how many events it missed or to request them be replayed. Disconnections silently dropped real-time updates.
-
No
lastSeqon reconnect. The subscribe message was not sent on connection open, so the server had no signal to initiate replay. -
Connection status not exposed. The hook returned only
{ send }. Consumers (including the UI) had no way to know whether the socket was connecting, connected, or reconnecting without re-implementing the state tracking themselves. -
Missing dependency in
useCallback.config.messageReorderRatewas absent from theconnectdependency array, meaning changes to the reorder chaos setting would not take effect until the next natural reconnect.
Decision
Upgrade both the WebSocket server and the client hook to implement a full reliability protocol.
Server changes (server/src/index.ts)
-
Global sequence counter. Every event gets
seq: ++globalSeq. The counter is monotonically increasing for the server's lifetime. -
Unique event IDs. Every event gets
eventId: crypto.randomUUID(). Stable per emission: replays carry the sameeventIdas the original, enabling idempotent processing. -
Event buffer. Last 200 events are retained in a ring buffer. Oldest events are evicted when the buffer is full.
-
Replay on subscribe. When a client sends
{ type: 'subscribe', lastSeq: N }:- Find all buffered events with
seq > N. - Send them in order with
replayed: true. - Send
replay_completewhen finished. - If
Nis older than the oldest buffered event, sendreplay_overflowinstead: the client must do a full refetch.
- Find all buffered events with
-
currentSeqin welcome message. Theconnectedmessage includes the server's current sequence so clients know where the stream starts.
Protocol flow
The full reliability lifecycle: normal delivery → disconnect → reconnect with replay → deduplication of a retransmitted event.
Client changes (useWebSocket.ts)
-
Deduplication window. A bounded
Set<string>of recently seeneventIds. Size capped at 200. Oldest entry evicted on overflow. On arrival of any event with a knowneventId: discard, write warning log. -
Sequence tracking.
lastSeqRefpersists across reconnects. On each event: ifseq > lastSeqRef + 1, log a gap warning. UpdatelastSeqReftoseqif greater. -
Subscribe on open. On
ws.onopen, immediately send{ type: 'subscribe', lastSeq: lastSeqRef.current }. This triggers server-side replay of any missed events. -
Connection status state. Exported as
status: WSConnectionStatus. Values:'connecting' | 'connected' | 'reconnecting' | 'disconnected'. Consumers can use this to render reconnection banners or disable interactive elements during degraded states. -
lastSeqexposed. Exported aslastSeq: number. Enables consumers to show "last synced at seq N" in development panels. -
Fixed dependency array.
messageReorderRateadded toconnectdependencies. Chaos setting changes now take effect immediately. -
Clean unmount.
ws.onclose = nullis set before closing during unmount. Prevents the close handler from scheduling a reconnect for a component that is no longer mounted.
Design decisions and tradeoffs
Why a Set for deduplication, not a Map?
A Map would let us store arrival timestamps for TTL-based eviction ("forget events older than 60 seconds"). A Set is simpler and O(1) for both insert and lookup. The bounded-size eviction (remove oldest when full) is a reasonable approximation for short-session deduplication.
In production: Use a Map with TTL eviction. The window size should be based on the maximum expected replay latency, not an arbitrary count.
Why seq and eventId as separate fields?
They solve different problems. seq detects gaps: if you see 41 then 43, you know 42 is missing. eventId deduplicates: two deliveries of the same event have the same eventId regardless of when they arrive.
You cannot use seq for deduplication: a replayed event arrives with its original seq, which the client may have already seen and tracked as lastSeq. The client needs to know "this is the same event I processed before, not a gap in the sequence."
Why 200 events in the buffer?
At 5-second event intervals (the server's default transaction rate), 200 events represents ~17 minutes of history. Most transient disconnections (network hiccups, brief offline periods) are shorter than this.
In production: The buffer size should be based on your SLA for replay. If your SLA is "clients should recover from disconnections up to 30 minutes old," you need a persistent replay log (a database table, or a Kafka topic with a retention policy), not an in-memory buffer.
Why exponential backoff with jitter?
Without jitter, all clients that disconnect simultaneously (a server restart) will all retry at the same moment, creating a reconnect storm that overwhelms the restarted server. Random jitter distributes the reconnect load over time.
The formula used:
delay = min(1000 * 2^attempt + random(0, 1000), 30_000)
| Attempt | Min delay | Max delay |
|---|---|---|
| 0 | 1.0s | 2.0s |
| 1 | 2.0s | 3.0s |
| 2 | 4.0s | 5.0s |
| 3 | 8.0s | 9.0s |
| 4 | 16.0s | 17.0s |
| 5+ | 30.0s | 31.0s |
This pattern is used by AWS SDKs, Stripe's client libraries, and virtually every production real-time system.
Consequences
Positive:
- Duplicate events from at-least-once delivery are silently discarded.
- Short disconnections are transparent to the user: events are replayed automatically.
- Sequence gaps are logged, making debugging much easier in production.
- UI can show accurate connection status without reimplementing tracking.
Negative:
- The server's in-memory event buffer is lost on restart. After a server restart, clients will receive
replay_overflowand must do a full refetch. - The deduplication window (200 events) is a fixed size, not time-based. A slow connection that sees few events might evict older eventIds prematurely.
What this enables next
- UI
reconnectingbanner usingstatusfrom the hook - Dev panel showing
lastSeqand gap count replay_overflowhandler that triggersqueryClient.invalidateQueries()- Integration tests that verify deduplication and replay under chaos