DEV Community

RainerBarrett4745
RainerBarrett4745

Posted on

Node.js Player Disconnects in 2026: Lease-Based Lobby Host Succession

A lobby should survive its host disappearing without asking every client to guess what happened. The least complex design that does that is a server-owned host lease, a deterministic successor rule, and a replayable event log. Express exposes commands and backfill; the authoritative transition belongs in one serialized state change.

Choice Reconnect behavior Backfill behavior Main risk
Immediate reassignment Return as a member Replay after the last sequence Brief loss causes host churn
Lease, then successor Resume during the lease Replay the committed suffix Handoff waits for expiry
Client election Peers negotiate Peers may hold different gaps Split-brain authority

Recommendation: use a short server-side lease and choose the next host from an ordering the server owns. Persist the transition before broadcasting it. Clients receive facts; they do not elect authority.

TL;DR: mark a disconnected host as temporarily absent, accept a reconnect only while its session and lease still match, then commit one host.changed event if the lease expires. Give every event a monotonically increasing sequence so a returning player can request the missing suffix. If the old host comes back after takeover, it returns as a member.

How should Node.js handle a player disconnect and reassign the lobby host?

A close signal says one observed connection ended. It does not prove that the player intended to leave, and it cannot distinguish a dead tab from a laptop changing networks. WebRTC exposes the same useful distinction through separate disconnected, failed, and closed connection states; disconnected can be transient.

The server needs two clocks: a connection clock and an authority clock. The connection can vanish now. Authority expires later.

No vote.

Do not add a makeMeHost client command. That is config bloat wearing a security problem as a hat. The useful command is smaller: resume(sessionId, lastSeenSequence). The server decides whether the session may resume and what it missed.

Reconnect correctness comes first. A player session needs a stable ID that outlives one transport, while each transport gets its own connection ID. When a new connection resumes a session, invalidate the previous connection before attaching the new one. Otherwise two tabs can act through one identity. Keep a generation number on the session and require every mutation to carry it; stale connections then fail an equality check.

Backfill correctness is the other half. Assign a sequence only when an event commits. On reconnect, return events where sequence > lastSeenSequence, in ascending order, followed by the current sequence. If the cursor predates retained history, return a fresh snapshot and its sequence instead of pretending the gap can be filled.

The lease delays failover, but reduces authority churn during transient disconnects. There is no universal duration. Measure reconnect latency for the actual audience, choose a starting window, and record how often hosts return inside it. Guessing is cheap. Instrumentation settles it.

Measure it.

Benchmark the state-machine boundary, not a browser first. The useful numbers are transition latency, duplicate-transition count, and backfill size by percentile. Time-to-first-call matters, but a tiny client API is good DX only when its failure semantics fit on one screen.

A serialized TypeScript transition

The store below is deliberately an interface. In one Node.js process, a per-lobby queue can serialize it. With several processes, the implementation needs the same compare-and-swap guarantee in shared storage. A host change commits only if the expected host and lease deadline still match.

type Player = {
  id: string;
  joinedAt: number;
  generation: number;
  connected: boolean;
};

type Lobby = {
  id: string;
  hostId: string;
  hostLeaseUntil: number | null;
  sequence: number;
  players: Player[];
};

type LobbyEvent =
  | { sequence: number; type: "host.absent"; playerId: string; leaseUntil: number }
  | { sequence: number; type: "host.changed"; previousId: string; playerId: string };

interface LobbyStore {
  transact<T>(lobbyId: string, change: (lobby: Lobby) => T): Promise<T>;
  append(lobbyId: string, event: LobbyEvent): Promise<void>;
}

const HOST_LEASE_MS = 15_000;

async function disconnect(
  store: LobbyStore,
  lobbyId: string,
  playerId: string,
  generation: number,
  now: number,
): Promise<void> {
  await store.transact(lobbyId, async (lobby) => {
    const player = lobby.players.find((item) => item.id === playerId);
    if (!player || player.generation !== generation || !player.connected) return;

    player.connected = false;
    if (lobby.hostId !== playerId) return;

    const leaseUntil = now + HOST_LEASE_MS;
    lobby.hostLeaseUntil = leaseUntil;
    await store.append(lobbyId, {
      sequence: ++lobby.sequence,
      type: "host.absent",
      playerId,
      leaseUntil,
    });
  });
}

async function expireHostLease(
  store: LobbyStore,
  lobbyId: string,
  expectedHostId: string,
  expectedLeaseUntil: number,
  now: number,
): Promise<void> {
  await store.transact(lobbyId, async (lobby) => {
    if (lobby.hostId !== expectedHostId) return;
    if (lobby.hostLeaseUntil !== expectedLeaseUntil || now < expectedLeaseUntil) return;

    const successor = lobby.players
      .filter((player) => player.connected && player.id !== expectedHostId)
      .sort((a, b) => a.joinedAt - b.joinedAt || a.id.localeCompare(b.id))[0];
    if (!successor) return;

    lobby.hostId = successor.id;
    lobby.hostLeaseUntil = null;
    await store.append(lobbyId, {
      sequence: ++lobby.sequence,
      type: "host.changed",
      previousId: expectedHostId,
      playerId: successor.id,
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Fifteen seconds is an example policy, not an ideal threshold. Make it one server setting, not a forest of client flags. Ordering by joinedAt, then id, produces one answer even for equal timestamps. Filtering for connected players avoids handing authority to another absent session.

There is a storage trap here: append must join the same atomic commit as the lobby mutation. Commit state plus an outbox record, then broadcast the outbox. A crash between a row update and a separate publish otherwise creates state that clients never hear about.

Reconnect and backfill through Express

Express is useful at the boundary. It authenticates the player, validates the cursor, and calls the state machine. It should not contain the successor rule.

import express, { type Request, type Response } from "express";

type ResumeResult = {
  generation: number;
  currentSequence: number;
  role: "host" | "member";
  events: LobbyEvent[];
};

interface SessionService {
  resume(input: {
    lobbyId: string;
    playerId: string;
    sessionId: string;
    lastSeenSequence: number;
  }): Promise<ResumeResult>;
}

export function createApp(sessions: SessionService) {
  const app = express();
  app.use(express.json());

  app.post("/lobbies/:lobbyId/resume", async (req: Request, res: Response) => {
    const cursor = Number(req.body.lastSeenSequence);
    if (!Number.isSafeInteger(cursor) || cursor < 0) {
      res.status(400).json({ error: "invalid_cursor" });
      return;
    }

    const result = await sessions.resume({
      lobbyId: req.params.lobbyId,
      playerId: String(req.body.playerId),
      sessionId: String(req.body.sessionId),
      lastSeenSequence: cursor,
    });
    res.json(result);
  });

  return app;
}
Enter fullscreen mode Exit fullscreen mode

The returned role is current truth. A late reconnect cannot reclaim authority because its cache says host. The generation must accompany later mutations, while currentSequence becomes the next backfill cursor. Small surface. Explicit contract.

Test with fake time. Start with three players, disconnect the host, reconnect just before expiry, and assert there is no host.changed. Repeat with expiry first and assert exactly one change even when two workers request it. Finally reconnect the former host using a cursor from before handoff; it must receive host.absent and host.changed in order and get the member role.

A browser-only happy path misses the race. Run the transition hundreds of times with both operations scheduled at the same timestamp. One run should enqueue reconnect first; another should enqueue expiry first; a third should pause one worker after it reads the lobby and let the other commit. The reconnect-first case cancels the lease and retains the host. The expiry-first case changes the host, increments the sequence, and makes the returning player a member. The paused stale worker must do nothing when its expected values no longer match. Repeat the same set with no connected successor: authority stays with the absent host until a player becomes eligible or an explicit empty-lobby policy ends the lobby. This is why a timer callback cannot contain an unconditional assignment. The invariant matters, not which callback wins: one host, unique sequences, one committed transition for each sequence, and the current generation on every accepted mutation.

When immediate reassignment is better

The runner-up wins when the host has no durable authority. If the role controls only a cosmetic badge, waiting adds dead time for little benefit. Immediate reassignment also fits disposable sessions where reconnect always means joining fresh. This lease design has a real limitation: it is not suitable when the product promises instant takeover, and it adds a timer plus atomic-storage work that a single-process, noncritical lobby may not need. In that case, choose immediate server-side reassignment and keep the same deterministic ordering.

A lease earns its moving parts when host actions affect shared state and ordinary network transitions should not reshuffle control. Resist adding negotiation messages, priority scores, and client heartbeat switches until measurements demand them. Each switch expands the race matrix.

Operationally, alert on impossible outcomes: two active hosts, a repeated sequence, or a change whose previous ID differs from committed state. Track lease expirations and successful in-lease resumes separately. Logs need lobby ID, player ID, connection generation, sequence, and transition name, but no session secret.

The rule is blunt: authority follows committed server state, never socket timing. A transport reports presence. The lease absorbs ambiguity. The ordered log repairs what a reconnecting player missed.

Further reading

Top comments (0)