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.
Why state has to live somewhere
Section titled “Why state has to live somewhere”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.
Attaching state to the socket
Section titled “Attaching state to the socket”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”).
Mutating state as messages arrive
Section titled “Mutating state as messages arrive”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.
Clean up on close — always
Section titled “Clean up on close — always”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"]
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));See state in action
Section titled “See state in action”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.
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.
Common mistakes
Section titled “Common mistakes”Storing too much state on every connection Symptoms:
- Storing the full user profile, permission list, and history in the connection object
- 10,000 connections means 10,000 copies of the same data
- Store just an identifier on the connection; fetch data from a shared store when needed
State that isn’t cleaned up on disconnect Symptoms:
- The connection disconnects but its state stays in the in-memory map
- Memory leak — the server slows down over time
- Always implement
ws.on('close', () => { connections.delete(userId) })
💡 Real-world example
The general shape large chat gateways use (no specific store is named, since no primary source confirms the internal infrastructure details):
- Store only connection-level identifiers: userId, the relevant guild/workspace id, and the channels it’s subscribed to
- Session state that must survive a reconnect lives in an external session store, decoupled from the connection’s own process — not tied to a process that disappears on restart
- Heavy data (full profile, history) stays in the primary database and is queried on demand, never cached on the connection object