Skip to content

Connection State

A bare socket is anonymous. The server knows a client is connected, but not who they are or what they care about. Real applications need to answer questions like “which user is this?” and “what are they subscribed to?” — and the answers have to travel with the connection for its whole life. That is per-connection state.

In HTTP you rebuild context on every request from a token or a session cookie, because each request is independent. A WebSocket is the opposite: one long-lived connection, so you want to resolve “who is this” once, at connect time, and keep it. Typical things you attach:

  • Identity — a user id, resolved from a token on the upgrade request.
  • Subscriptions — which rooms, channels, or topics this socket wants.
  • Bookkeeping — when they connected, the last time you heard from them (for heartbeats), a per-socket counter.

The natural place to put all of this is on the socket object itself, so that wherever you have the ws, you have its context.

The simplest pattern is to hang a plain object on the socket when the connection opens. Read identity from the upgrade req, then store whatever you need:

wss.on('connection', (ws, req) => {
// Resolve identity once, from the upgrade request (token, cookie, query…).
const userId = resolveUser(req); // your auth logic
// Attach a state bag to this specific socket.
ws.state = {
userId,
subscriptions: new Set(),
connectedAt: Date.now(),
};
ws.send(`hello ${userId}`);
});

Now any handler that has the socket can read ws.state.userId or inspect ws.state.subscriptions. A cleaner, type-safe alternative in larger apps is a side-table — a Map from socket to state — which avoids monkey-patching the library’s object:

const stateBySocket = new Map(); // ws -> { userId, subscriptions, ... }
wss.on('connection', (ws, req) => {
stateBySocket.set(ws, {
userId: resolveUser(req),
subscriptions: new Set(),
});
});

Both work. Attaching directly is convenient; the side-table keeps the socket object clean and is easy to reason about when you also want a reverse lookup (for example, “find the socket for user 42”).

State is not frozen at connect time — it evolves. A subscribe message should add to the set; an unsubscribe should remove from it:

ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'subscribe') {
ws.state.subscriptions.add(msg.channel);
} else if (msg.type === 'unsubscribe') {
ws.state.subscriptions.delete(msg.channel);
}
});

This per-socket subscriptions set is the seed of rooms, which the next lesson grows into a full broadcast system.

Here is the rule that separates a robust server from a leaky one: every bit of state you create on connect, you must tear down on close. A socket that disconnects but leaves its entry in a Map, or its id in a room set, is a memory leak and a correctness bug — you might broadcast to a ghost.

flowchart LR
  CONN["connection<br/>(ws, req)"] --> CREATE["create state<br/>userId, subscriptions"]
  CREATE --> LIVE["live: read & mutate<br/>on each message"]
  LIVE --> CLOSE["close / error"]
  CLOSE --> CLEAN["delete state<br/>remove from rooms"]
  CLEAN --> GC["socket eligible<br/>for garbage collection"]
The lifecycle of per-connection state

Wire the cleanup to both close and error, because an error often precedes a silent disconnect:

function cleanup(ws) {
stateBySocket.delete(ws);
// also remove ws from any room sets it joined
}
ws.on('close', () => cleanup(ws));
ws.on('error', () => cleanup(ws));

The demo below keeps per-connection state (a user id derived at connect time, plus a subscription set), mutates it on subscribe/unsubscribe messages, and prints a teardown line on close. Open it in StackBlitz and connect — the modified client lines are noted in the comments.

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

Each reply echoes the socket’s current subscription list — state that persists across messages and belongs to that one connection. When the client disconnects, the cleanup line proves the teardown ran. Keep that discipline and your server’s memory stays bounded no matter how many clients come and go.

Why resolve a connection’s identity once at connect time rather than on every message?
What is the danger of NOT cleaning up per-connection state on close?
Why attach the cleanup handler to both `close` and `error`?