A dropped socket should not immediately crown a new lobby host. Mobile networks pause, laptops sleep, and tabs reconnect. The operational constraint is ambiguity: a server can observe that a connection disappeared, but it cannot infer the player's intent from that event alone.
TL;DR: keep host authority on the server, give a disconnected host a short lease, and elect the connected player with the lowest server-issued join sequence when that lease expires. Clients may request typing indicators and read receipts with a room-scoped token. They may never nominate a host. This keeps the implementation small enough to ship while closing the trust gap that causes split-brain lobbies.
For a one-person developer-tools SaaS, that boundary matters more than a clever election algorithm. Every hour spent reconciling two “current hosts” is an hour not spent shipping. I would outsource transport plumbing, but keep the tiny state machine that defines product behavior.
How should Node.js handle a player disconnect and reassign the lobby host?
WebSocket closure is evidence about one connection. It is not proof that the player left the lobby. A user can reconnect with a fresh socket while the old connection is still timing out, and a single user can have two tabs. If each client independently chooses the next host, message order decides authority. Different clients can therefore render different hosts.
Picture a four-player room with server-issued join sequences 1 through 4. Player 1 is host. Their socket closes, so the server starts the 8-second lease but leaves hostId alone. At 3 seconds, player 2 sees no typing activity from player 1; that observation changes nothing because presence is not authority. At 6 seconds, player 1 reconnects with a fresh one-time ticket. The server binds that connection to the existing player record, cancels the lease, increments the room version, and publishes the current state. No election occurred. In the alternate path, player 1 stays absent past 8 seconds. The timeout removes that membership, filters for players with live sockets, and chooses player 2 because sequence 2 is the smallest remaining value. If an older snapshot then arrives at a client after the election event, the client retains the event with the higher version. One timeline covers the key boundaries: connection identity, player identity, lease expiry, deterministic eligibility, and stale delivery.
This race is the bug.
The server needs one stable player identity above individual connections. It also needs a monotonically increasing join sequence assigned by the authority that owns the room. Do not use client clocks for the ordering rule. Do not let a client send newHostId either.
The smallest useful state machine has four transitions:
-
join: attach a connection to a player and assignjoinedSeqonly on the player's first entry. -
disconnect: detach that connection and start a host lease only when the player has no other live connections. -
reconnect: attach the new connection and cancel the pending lease. -
leaseExpired: remove the absent player and, if needed, select the connected player with the lowestjoinedSeq.
That last rule is intentionally boring. It is deterministic, easy to test, and cheap to explain during an incident. Revenue per hour favors boring.
The smallest working server
This example uses Express for a one-time WebSocket ticket and ws for transport. The process is authoritative. A normal browser cannot add an arbitrary Authorization header to the WebSocket constructor, so the HTTP endpoint exchanges the application's authenticated session for a short-lived, single-use ticket. The sample leaves requireUser as an integration boundary because session verification is application-specific.
The ticket is scoped to one user and one room. It grants no host-management capability. The 8-second lease and 30-second ticket lifetime below are explicit policy values, not universal recommendations.
import express, { type Request, type Response } from "express";
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { WebSocketServer, type WebSocket } from "ws";
type Player = {
id: string;
joinedSeq: number;
sockets: Set<WebSocket>;
lease?: NodeJS.Timeout;
};
type Lobby = {
hostId?: string;
nextSeq: number;
version: number;
players: Map<string, Player>;
};
type Ticket = {
roomId: string;
userId: string;
expiresAt: number;
};
const HOST_LEASE_MS = 8_000;
const TICKET_TTL_MS = 30_000;
const app = express();
const server = createServer(app);
const sockets = new WebSocketServer({ noServer: true });
const lobbies = new Map<string, Lobby>();
const tickets = new Map<string, Ticket>();
function requireUser(req: Request): string {
const userId = req.header("x-authenticated-user");
if (!userId) throw new Error("unauthenticated");
return userId;
}
app.post("/rooms/:roomId/socket-ticket", (req: Request, res: Response) => {
try {
const ticket = randomUUID();
tickets.set(ticket, {
roomId: req.params.roomId,
userId: requireUser(req),
expiresAt: Date.now() + TICKET_TTL_MS,
});
res.status(201).json({ ticket });
} catch {
res.sendStatus(401);
}
});
function lobbyFor(roomId: string): Lobby {
let lobby = lobbies.get(roomId);
if (!lobby) {
lobby = { nextSeq: 1, version: 0, players: new Map() };
lobbies.set(roomId, lobby);
}
return lobby;
}
function connectedPlayers(lobby: Lobby): Player[] {
return [...lobby.players.values()].filter((p) => p.sockets.size > 0);
}
function publish(roomId: string, lobby: Lobby): void {
lobby.version += 1;
const event = JSON.stringify({
type: "lobby.state",
version: lobby.version,
hostId: lobby.hostId ?? null,
playerIds: connectedPlayers(lobby).map((p) => p.id),
});
for (const player of connectedPlayers(lobby)) {
for (const socket of player.sockets) socket.send(event);
}
}
function electHost(lobby: Lobby): string | undefined {
return connectedPlayers(lobby)
.sort((a, b) => a.joinedSeq - b.joinedSeq)[0]?.id;
}
function attach(roomId: string, userId: string, socket: WebSocket): void {
const lobby = lobbyFor(roomId);
let player = lobby.players.get(userId);
if (!player) {
player = { id: userId, joinedSeq: lobby.nextSeq++, sockets: new Set() };
lobby.players.set(userId, player);
}
if (player.lease) clearTimeout(player.lease);
player.lease = undefined;
player.sockets.add(socket);
if (!lobby.hostId) lobby.hostId = electHost(lobby);
publish(roomId, lobby);
socket.on("close", () => {
player?.sockets.delete(socket);
if (!player || player.sockets.size > 0) return;
if (lobby.hostId !== userId) {
lobby.players.delete(userId);
publish(roomId, lobby);
return;
}
player.lease = setTimeout(() => {
if (!player || player.sockets.size > 0) return;
lobby.players.delete(userId);
lobby.hostId = electHost(lobby);
publish(roomId, lobby);
}, HOST_LEASE_MS);
});
}
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "/", "http://localhost");
const token = url.searchParams.get("ticket");
const grant = token ? tickets.get(token) : undefined;
if (token) tickets.delete(token);
if (!grant || grant.expiresAt < Date.now()) {
socket.destroy();
return;
}
sockets.handleUpgrade(req, socket, head, (ws) => {
attach(grant.roomId, grant.userId, ws);
});
});
server.listen(3000);
This is deliberately in memory. It makes one promise: within one process, every mutation passes through the same room object. It does not pretend that a Map becomes distributed state when a second process starts.
One detail is easy to miss. A non-host disappears immediately in this minimal version, while the host gets a grace period. That is a product choice suited to host continuity, not a transport requirement. If presence accuracy matters equally for every player, apply leases to all players and expose a reconnecting state.
Keep typing and read receipts outside the authority path
Typing indicators are ephemeral hints. Read receipts are user-scoped acknowledgements. Neither should share the command path that changes lobby ownership.
After the socket is attached, the server can accept a narrow union of messages and derive roomId and userId from the consumed ticket's connection context. The payload must not be allowed to override either value.
type ClientEvent =
| { type: "typing.set"; active: boolean }
| { type: "message.read"; messageId: string };
type ConnectionContext = {
roomId: string;
userId: string;
};
function handleClientEvent(
context: ConnectionContext,
event: ClientEvent,
): void {
if (event.type === "typing.set") {
broadcastEphemeral(context.roomId, {
type: event.type,
userId: context.userId,
active: event.active,
});
return;
}
recordReadReceipt({
roomId: context.roomId,
userId: context.userId,
messageId: event.messageId,
});
}
broadcastEphemeral and recordReadReceipt are domain ports here, not hidden product dependencies. The first may tolerate loss. The second usually needs idempotency because reconnecting clients can repeat an acknowledgement. Host changes need a separate server-generated event with a version so clients can discard older snapshots that arrive late.
The trust rule is compact: clients report intent; the server assigns identity, ordering, and authority. A room token should not work in another room. A socket ticket should not be reusable. A receipt from one user should not be accepted as another user's receipt.
Test the race, not just the happy path
A unit test that closes one socket and expects a new host proves little. The valuable cases sit at transition boundaries.
Use a fake clock and assert these sequences: the host reconnects at 7,999 ms and remains host; the lease expires and the earliest connected player wins; two tabs exist and closing one does not start the lease; a consumed ticket is rejected on reuse; and an old state version cannot replace a newer version in the client store. Also test an empty lobby. Its host must become undefined, not a stale player ID.
Then add an integration test with real sockets. Force-close the host connection, reconnect before the configured deadline, and inspect the state events received by a second player. Avoid asserting exact network timing. Assert ordering and eventual state after advancing beyond the lease.
Logging should carry roomId, userId, connectionId, state version, and a reason such as lease_expired. Do not put the socket ticket in logs. Useful counters include active connections, leases started, leases canceled by reconnect, and host elections. Those signals answer the practical question: are elections caused by real departures or by unstable transport?
What I would change at scale
The first change is ownership of room mutations. Route every command for a room to one logical worker, or guard each mutation with a transactional compare-and-set on the room version. A basic cross-process publish/subscribe channel alone does not serialize competing elections. Two workers can both observe a timeout and publish different winners.
Persist joinedSeq, membership, host ID, lease deadline, and version together. Store a deadline rather than relying solely on an in-process timer, because processes restart. A worker that acquires the room can compare the deadline with its server clock and finish an overdue transition. Elections should be idempotent against the expected prior version.
I would also separate durable state events from lossy presence events. Typing can vanish without repair. A host assignment cannot. Read receipts fall between them: the latest receipt per user and conversation is often the useful state, so replacing an older position can be cheaper than retaining every click. That choice belongs to the product model.
The trade-off is more infrastructure and more operational surface. Stay with one process while its failure domain is acceptable and the lobby state is disposable. Move to durable coordination before horizontal workers can concurrently own the same room. Shipping weekly means postponing machinery, not postponing an explicit boundary.
The decision rule
Use a server-side lease when a brief disconnect should preserve authority. Use deterministic election from server-owned membership when the lease ends. Scope connection credentials to one identity and room, and keep presence features incapable of changing roles.
The code is small. The policy is the product: lease duration, eligibility, and what survives a restart. Write those three decisions down, test the races around them, and measure elections by reason. That gives a solo operator a system that can evolve without handing trust to whichever client packet arrives last.
Top comments (0)