Reconnecting Signaling Sockets Without Losing Session State
A WebSocket that dies mid-call is a routine event, not an incident: a lift, a Wi-Fi-to-LTE handover, a rolling deploy of the signalling tier. This guide is part of the WebSocket Signaling Implementation guide, and it solves exactly one problem — how a client re-attaches to its existing session after the transport underneath it disappears, without the server treating it as a stranger and without either peer renegotiating media that never stopped flowing. Three mechanisms do the work: a resumption token that survives the socket, a bounded outbound buffer on the client, and monotonic message ids that make redelivery harmless.
Context & Trade-offs
The instinct when a socket closes is to tear everything down and rebuild. That instinct is wrong, and expensive. Media in WebRTC travels over an ICE-nominated candidate pair protected by DTLS-SRTP; the signalling channel is only involved when a session description changes. Kill the WebSocket during an established call and the peer connection notices nothing at all — iceConnectionState stays connected, frames keep decoding, and getStats() polled at 1 s intervals shows bytesReceived climbing straight through the outage. Rebuilding the peer connection because the signalling transport blinked costs a full ICE gather, a DTLS handshake, and a keyframe — typically 1–3 s of black video for something the user would otherwise never have seen.
So the design target is not “recover the call”. It is “recover the mailbox”. Everything the client would have said during the outage must be delivered afterwards, in order, exactly once in effect; everything the server tried to say must be replayed. That reduces to a session identity that outlives the transport and a sequence space both ends agree on.
The trade-off is in how long the server holds the abandoned session. Too short and a client crossing a slow cellular handover comes back to SESSION_EXPIRED and must cold-join. Too long and you leak room membership: a peer that genuinely left keeps a phantom slot, other clients still render its tile, and glare-prone renegotiation logic has a ghost to collide with. A 30–60 s grace TTL is the usual landing zone, because it comfortably covers the 3–5 s of reconnect backoff plus a handover, while staying short enough that a genuine departure clears before anyone notices.
Three recovery strategies are available when the socket dies, and they differ by roughly an order of magnitude in user-visible cost:
| Strategy | What survives | Cost to the user | When it is right |
|---|---|---|---|
| Resume session | peer connection, room slot, peer ID, message order | none — nothing renders differently | any drop inside the grace window |
| Cold join, keep media | peer connection only | new peer ID, tiles may re-key | token expired but ICE still connected |
| Full rebuild | nothing | 1–3 s of frozen video, fresh gather + DTLS | ICE reports failed, or codecs changed |
The middle row is the one teams forget to build, and it is the one that saves you when a deploy takes the signalling tier down for longer than the grace TTL. A client whose token has expired but whose media is still healthy should re-register as a new participant and then reconcile — not blow away a working RTCPeerConnection. Reconciliation here means re-announcing current track state (muted, screen-sharing, layer preferences) rather than re-offering, since the transceivers on both sides are unchanged.
# Signalling session-resumption tunables (server side)
resume_token_bytes = 32 # 256 bits of CSPRNG entropy, opaque to the client
resume_grace_ttl = 45 # seconds a disconnected session survives before hard teardown
outbox_replay_depth = 256 # max messages retained per session for gap replay
outbox_max_bytes = 262144 # hard cap; exceed it and force a cold join instead of replay
client_buffer_depth = 64 # outbound messages a client queues while the socket is down
reconnect_backoff_cap = 10 # seconds, with plus or minus 30 percent jitter
Minimal Runnable Implementation
The client keeps two things across socket generations: the opaque resumeToken handed to it on first join, and lastServerSeq, the highest sequence number it has fully processed. Everything it wants to send while disconnected goes into a bounded array with a client-assigned msgId. On reopen it sends one resume frame; the server either accepts it and replays the gap, or rejects it and the client falls back to a cold join.
// --- Client: session-aware signalling socket -------------------------------
let resumeToken = null; // opaque, issued by the server on first join
let lastServerSeq = 0; // highest server seq this client has applied
let outbound = []; // messages produced while the socket was down
let attempt = 0;
function send(msg) {
// Stamp an idempotency key so a redelivered frame is a no-op, not a duplicate
msg.msgId ??= `${resumeToken?.slice(0, 8) ?? 'new'}-${crypto.randomUUID()}`;
if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); return; }
if (outbound.length >= 64) outbound.shift(); // bounded queue: drop oldest, never grow
outbound.push(msg);
}
function connect() {
ws = new WebSocket('wss://signal.example.com/ws');
ws.onopen = () => {
attempt = 0;
// One frame decides resume vs cold join; never send call traffic before the verdict
ws.send(JSON.stringify(
resumeToken ? { type: 'resume', resumeToken, lastServerSeq }
: { type: 'join', roomId }
));
};
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === 'resumed') {
// Server confirmed identity; flush what we queued, in original order
for (const q of outbound) ws.send(JSON.stringify(q));
outbound = [];
return;
}
if (m.type === 'resume-failed') { // token expired or unknown
resumeToken = null; lastServerSeq = 0; outbound = [];
ws.send(JSON.stringify({ type: 'join', roomId }));
return;
}
if (m.seq <= lastServerSeq) return; // replayed frame we already applied
lastServerSeq = m.seq; // advance only on in-order delivery
handleSignal(m); // offer / answer / candidate
};
ws.onclose = (e) => {
if (e.code === 1000) return; // deliberate hang-up, stay closed
const base = Math.min(500 * 2 ** attempt++, 10000);
setTimeout(connect, base + base * 0.3 * (Math.random() * 2 - 1)); // ±30% jitter
};
}
The server side is the mirror image: a sessions map keyed by token holding { roomId, peerId, outbox, expiresAt }, plus a seq counter incremented on every frame it emits to that peer. On resume, it validates the token, cancels the pending teardown timer, rebinds the new socket into the room Set in place of the dead one, and replays outbox entries with seq > lastServerSeq. On join, it mints a fresh token and returns it. The handshake looks like this.
Two details in that server loop are easy to get wrong. First, the rebind must be a swap, not an add: delete the dead socket from the room Set in the same tick you insert the new one, or the next fan-out iterates a closed socket and every send() to it throws or silently buffers. Second, the outbox has to be trimmed on acknowledgement, not only on expiry. Advance a per-session ackedSeq whenever the client reports lastServerSeq — on resume, and opportunistically on any heartbeat — and drop everything at or below it. Without that, a long call accumulates every candidate and mute event it ever forwarded, and a session that reconnects after twenty minutes tries to replay hundreds of dead frames in one burst, which is precisely the backpressure spike you spent effort avoiding elsewhere.
Sequence numbers should be per-session, not global, and they must be assigned at fan-out time rather than at ingress. A global counter shared across rooms leaks activity volume to any client that can watch its own gaps, and an ingress-assigned number tells the client nothing useful about what it was owed. One monotonic integer per recipient session is the smallest thing that makes the phrase “everything after 7” unambiguous.
If your signalling tier runs more than one process, the session record cannot live in a node-local Map — a reconnect lands on whichever node the load balancer picks, which is rarely the one that died. Put sess:<token> in the same shared backplane you already use for room fan-out, as described in Scaling WebSocket Signaling with Redis Pub/Sub.
Reproduction Steps & Debugging Log Patterns
- Establish a two-peer call and confirm media is flowing, then start a 1 s
getStats()poll printinginbound-rtp.bytesReceivedso you have a continuity trace to check afterwards. - Kill the socket without touching the peer connection: in DevTools, throttle to Offline for 5 s, or run
ws.close(1006)from the console on a build that exposes the socket. - While the socket is down, mute and unmute a track and add a fake ICE candidate, so at least two frames land in the outbound buffer.
- Restore the network and watch the resume handshake. Confirm the replayed server frames carry sequence numbers you have already seen and are dropped by the
m.seq <= lastServerSeqguard. - Repeat with the token deliberately corrupted to exercise the
resume-failedpath and confirm the client cold-joins cleanly rather than hanging.
A healthy run logs like this:
// t+0.0s ws close code=1006 reason="" // abnormal, expected on handover
// t+0.0s buffering outbound msgId=ab12-… // queue depth 1
// t+1.4s buffering outbound msgId=ab12-… // queue depth 3
// t+0.5s reconnect attempt=1 delay=612ms // 500ms base + jitter
// t+2.6s reconnect attempt=3 delay=2410ms
// t+4.6s ws open → resume lastServerSeq=7
// t+4.6s resumed replay=4 (seq 8..11) // server-side gap closed
// t+4.6s duplicate seq=8 ignored // idempotency guard doing its job
// t+4.6s flushed 3 buffered messages
// t+4.6s iceConnectionState=connected // unchanged for the whole window
The line that matters most is the last one. If iceConnectionState shows disconnected or failed in this trace, the socket was not your only problem — the same network event took the media path down too, and the fix belongs to Disconnected vs Failed ICE States rather than to session resumption. Only a genuine failed warrants createOffer({ iceRestart: true }), capped at 3 attempts, and that offer rides the socket you have just resumed.
Common Implementation Mistakes
- Rebuilding the
RTCPeerConnectionon socket close. The single most common and most costly error. You pay a full ICE gather, DTLS handshake and keyframe to fix something that was never broken. - Letting the client choose its own session identity. A resume token must be server-minted CSPRNG output, at least 32 bytes, and validated against the room it claims. A guessable or client-supplied token is a session-hijack primitive.
- An unbounded outbound buffer. A client offline for ten minutes with an uncapped queue replays a flood of stale ICE candidates on reconnect — candidates whose STUN bindings have long since expired, since mobile and CGNAT mappings can lapse in under 30 s. Cap the queue, and drop candidate frames older than a few seconds rather than sending them.
- Non-idempotent handlers. Replay is only safe if applying a message twice is a no-op. Track applied
msgIds and makeaddIceCandidateand mute-state updates tolerant of repeats; an offer applied twice throwsInvalidStateError. - Tearing the room slot down immediately on
close. If the disconnect handler removes the peer synchronously, there is nothing left to resume into. Mark the session pending and arm a TTL instead. - Resuming into a stale negotiation. If the peer sent an offer during the outage and you also queued one, you reconnect straight into glare; settle it with the polite-peer rule from Recovering from Glare in Offer Collisions.
FAQ
Should the resume token be the same value as the peer ID?
No. The peer ID is routing metadata that other participants legitimately see in forwarded frames; the resume token is a bearer credential. Reusing one as the other means any peer in the room can steal any other peer’s session. Mint them separately and never echo the token in fan-out traffic.
What happens to ICE candidates the remote peer trickled during my outage?
They sit in the server’s outbox and replay on resume, which is why the outbox must be a queue and not a last-write-wins slot. Candidates are additive and safe to apply late, though ones older than roughly 30 s may already refer to expired NAT bindings — apply them anyway, since a dead candidate simply fails its connectivity check.
How do I stop the resumed client from re-offering a description the peer already has?
Compare descriptions before acting. On resume, exchange a cheap fingerprint — the o= line’s session version from each side’s current local description — and only renegotiate if they disagree. This is the same discipline that keeps ordinary mid-call changes cheap, described in SDP Renegotiation Without Dropping Streams; a resume is just a renegotiation trigger you want to fire as rarely as possible.
Do I need this if I already handle Wi-Fi to cellular handover?
They are different layers of the same event. Handover work protects the media path, as covered in Handling Wi-Fi to Cellular Network Handover; session resumption protects the control path. A handover typically breaks both, and the ICE restart that fixes the media path needs a working signalling channel to carry its offer — so resumption has to land first.
Related: return to WebSocket Signaling Implementation, build the underlying server with WebSocket Signaling with Node.js & Socket.IO, formalise the transitions in Signaling State Machine Patterns, and handle the media-side recovery in Triggering an ICE Restart Without Dropping Media.