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.

Signaling outage timeline against an uninterrupted media plane A 30-second timeline. The signaling lane shows a connected socket until 8 seconds, a 4.5-second gap of backoff and buffering, then a resumed socket. The media lane shows one unbroken connected span for the whole 30 seconds. Socket dies at t=8 s — the media plane never learns about it signaling socket open 1006 + backoff resumed on same session token 3 outbound msgs buffered server outbox holds seq 8–11 media ICE connected · DTLS-SRTP flowing · no renegotiation 0 s 5 10 15 20 25 30 Only an ICE state of failed — not a socket close — justifies restartIce()
The signalling gap is a mailbox problem; the media plane runs straight through it untouched.

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.

Resume handshake between client, signalling node and session store The client sends a resume frame with its token and last sequence number. The node reads the session from a shared store, replays missed messages eight through eleven, and the client then flushes its buffered outbound frames, which the node appends to the outbox. Client (new socket) Signalling node Session store resume { token, lastServerSeq: 7 } GET sess:token · cancel TTL roomId, peerId, seq = 11 resumed + replay seq 8..11 flush 3 buffered candidates append outbox, seq → 12 peer connection untouched socket swapped in room Set TTL rearmed on close
One resume frame rebinds identity; replay closes the server-to-client gap, the flush closes the client-to-server gap.

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

  1. Establish a two-peer call and confirm media is flowing, then start a 1 s getStats() poll printing inbound-rtp.bytesReceived so you have a continuity trace to check afterwards.
  2. 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.
  3. 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.
  4. 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 <= lastServerSeq guard.
  5. Repeat with the token deliberately corrupted to exercise the resume-failed path 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

Session state machine spanning socket loss and resumption Live moves to Pending on an abnormal close, Pending moves to Resuming when a socket reopens, Resuming returns to Live if the token validates or falls through to Cold join if it expired. Pending decays to Session gone when the grace TTL elapses. LIVE socket bound PENDING buffering, TTL armed RESUMING token check COLD JOIN new peerId SESSION GONE slot released close 1006 reopen token ok · replay gap · flush token expired fresh join · full renegotiation grace TTL 45 s Only the COLD JOIN edge costs a renegotiation; every other path preserves the peer connection.
Four states, one expensive edge — the whole design exists to keep traffic off the cold-join path.

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.