Mobile network changes, proxy idle timeouts, browser suspension, and rolling deployments all break WebSocket connections. If a client merely waits one second after close and reconnects, it still does not know which events arrived, which commands the server committed, or how to prevent thousands of clients from reconnecting together.

WebSocket provides an ordered, bidirectional connection. It does not provide session continuity across connections. A reliable real-time feature defines cursors, replay, idempotency, heartbeat, and backpressure in its application protocol.

Separate a connection from a session

A connection is disposable transport. A session is the logical state of subscriptions, authorization, and progress. Exchange protocol messages immediately after connection instead of sending business events at once:

{"type":"hello","protocol":3,"resume_token":"…","last_seen":1842}
{"type":"welcome","session_id":"s_9","resumed":true,"head":1850}

The resume_token should be short-lived, tamper-resistant, and bound to the principal, tenant, and subscription scope. It is not a permanent access token; every handshake still checks current permission. A protocol version lets the server reject an incompatible client or choose an explicit downgrade path.

Do not keep the only copy of session state in one process. After a rolling deployment or load balancing to another instance, the subscription description, cursor, and replay source needed for recovery must be reconstructible.

Give every event a continuous position

Assign a monotonically increasing sequence to each recoverable stream. Persist last_applied only after an event has been validated and applied locally. Ignore a repeated sequence; when a gap appears, pause incremental application and request replay.

client last_applied = 1842
server replay        = 1843..1850
next live event      = 1851

Keep a replay log with a time or capacity limit. If the cursor is too old, the log has compacted, or authorization scope changed, return resync_required and fetch a snapshot instead of guessing. A snapshot needs a watermark such as “state includes through 1850.” The client installs it, then applies events from 1851 onward so there is no gap between snapshot and live traffic.

Define sequence scope. One global sequence is simple but can become a hotspot. A sequence per room or account scales more easily, but the client must retain multiple cursors. A timestamp is not a sequence because it can tie, arrive out of order, or reflect clock skew.

An acknowledgement is not exactly-once delivery

A client can send a command and disconnect after the server commits its side effect but before the acknowledgement arrives. Blindly retrying can create a duplicate order or message; never retrying can lose an action. Give every side-effecting command a client-generated command_id:

{"type":"send_message","command_id":"c_7f…","room":"r1","text":"hello"}

At the same durable boundary as the business write, the server stores that ID and result. A duplicate returns the original result without executing again. The client keeps unacknowledged commands in a bounded outbox and removes one when an acknowledgement carries the same ID. This is at-least-once transport with idempotent effect, not network-level exactly once.

Not every update deserves persistence. Typing state and pointer position can be dropped or coalesced across a disconnect. Payment, message send, and permission change need explicit acknowledgement. Declare the reliability class for each message type.

Reconnect with jittered backoff

RFC 6455 warns that reconnecting immediately after abnormal closure can cause a denial of service. It recommends a random initial delay and progressively longer waits. Use capped exponential backoff with full jitter, choosing from 0..min(cap, base*2^attempt), and reset the attempt only after a connection remains stable for a meaningful interval.

An online event means “a retry may be useful,” not “ignore backoff.” Background pages, power-constrained devices, and a server retry hint should also influence cadence. Do not retry authentication or policy rejection forever: refresh credentials or wait for user action. Stop on a protocol incompatibility and require an upgrade.

Use close codes with RFC/IANA semantics or the private application range, distinguishing normal closure, policy rejection, oversized messages, and server errors. A reason string is diagnostic text, not critical control data that clients must parse.

Heartbeats test the usable path

An existing TCP connection does not prove that a proxy and peer still carry application messages. A server can use RFC 6455 Ping/Pong. The browser WebSocket API does not expose control frames, so define application-level ping/pong when JavaScript needs observable round-trip time or liveness. Set the interval below known idle timeouts and add jitter so every connection does not fire on the same second.

Declare a connection dead only after expected responses miss a bounded deadline; one slow packet should not create a reconnect storm. Track the last frame, last business event, and last heartbeat separately to distinguish an idle stream from a dead path.

Backpressure belongs in the protocol

The browser’s bufferedAmount is the number of application bytes queued by send() but not yet transmitted. Set low and high watermarks. Above the high watermark, pause noncritical producers, coalesce replaceable updates, and reject new large messages. Resume below the low watermark. An unbounded array merely turns a slow network into an out-of-memory crash.

The server also limits inbound rate, message size, parse time, and outbound bytes per connection. When a slow consumer reaches its ceiling, discard only events declared lossy. For a reliable stream, close and let the cursor resume later rather than stalling the entire broadcast shard.

The handshake remains a security boundary

Browsers send Origin in the WebSocket handshake. Validate an allowlist, especially with cookie authentication, so a hostile site cannot establish a connection using the user’s session. Avoid long-lived bearer tokens in query strings that proxies and logs may capture. An HTTPS endpoint can issue a one-use, short-lived ticket for the handshake. Permission changes after connection still need enforcement through reauthorization or active disconnect.

During deployment, stop accepting new connections, send an application migration hint, perform the Close handshake for existing peers, and force close at the deadline. RFC 8441 changes how WebSocket is bootstrapped over HTTP/2; it does not supply application sequence or resume semantics.

Test disconnects before and after send, lost acknowledgements, duplicate and gapped events, replay expiry, events created during snapshot, flapping networks, proxy idle closure, slow consumers, browser suspension, expired tickets, permission revocation, and mass reconnection. Use a virtual clock to test backoff distributions without real waiting.

Monitor connection count, handshake failure reason, lifetime, close code, reconnect attempt, resume success, replay count, snapshot fallback, sequence gaps, outbox age, and queued bytes. A real-time system is only operable when its recovery path is visible.

Reliable WebSocket does not mean never disconnecting. It means knowing where to continue, what can be resent, when state must be rebuilt, and how slow connections degrade. Treat transport as temporary and session progress as a recoverable protocol; reconnect then becomes a verifiable capability rather than a timer-shaped patch.