DEV Community

MalachiNilsson7591
MalachiNilsson7591

Posted on

Node.js Full State Fan-Out: 2 Counters for Late Joiners

TL;DR: For a B2B SaaS chat room, publish a periodic full snapshot beside every incremental diff. Put snapshotVersion and diffVersion on the messages, and apply a diff only when its base matches the snapshot already held by the client. That gives a reconnecting or late-joining browser a bounded route to the current room state without building a per-client history service.

Choice Late joiner Fan-out cost Server state Pick it when
Diffs only Cannot reconstruct earlier state Low Low Never for authoritative room state
Snapshot plus diffs Converges at the next snapshot Predictable broadcast Current state plus two counters Most chat rooms and live dashboards
Per-client replay Replays from a cursor Depends on backlog Durable ordered history Every intermediate event matters

My default is the middle row. The hard part is not WebSocket syntax. It is deciding what a subscriber may safely apply after packets are delayed, duplicated, or observed only after a reconnect. Two event types and two monotonically increasing numbers make that decision explicit.

How should Node.js combine a snapshot plus diff for late joiners?

A diff such as { unread: 7 } has no meaning by itself. Seven since which state? If a browser opens after the preceding messages were sent, applying that diff to an empty object creates a plausible lie. Diffs alone leave the late joiner permanently wrong.

Use a snapshot version to name a complete baseline, then number the diffs derived from it. A client accepts snapshot 41, resets its expected diff to 1, and may then apply (41, 1), (41, 2), and so on. It rejects diffs for snapshot 40. If (41, 3) arrives before (41, 2), it waits for the next snapshot instead of guessing.

Stop guessing.

This is a convergence contract, not an exactly-once claim. A transport can redeliver an event, and a reconnect can cross a publish boundary. The client state machine makes duplicates harmless and gaps visible. A full snapshot every few seconds caps how long a subscriber can remain stale; it is usually less machinery than a backfill endpoint with cursors, retention, authorization, and cleanup for every room member.

For chat, keep transient effects out of the snapshot. Typing indicators can expire. The durable view should contain the data needed to render the room now: message summaries, membership, unread counts, and moderation state. If the product must preserve every message transition for audit, snapshot-plus-diff fan-out is no substitute for a durable event log.

The delivery guarantee is in the consumer

The publisher should serialize mutations per room. After each mutation, it increments diffVersion and emits a diff tied to the current snapshotVersion. On the snapshot timer, it increments snapshotVersion, resets diffVersion, captures the entire current view, and broadcasts it. Version allocation and state mutation belong in the same serialized critical section; otherwise two Node.js workers can assign conflicting numbers.

That last detail costs some throughput. I would take the trade. Room-local ordering is easier to benchmark and reason about than global ordering, while one sequence for the whole service turns an unrelated busy room into contention. Partition by roomId, then measure publish rate, snapshot payload bytes, convergence delay, and rejected-diff count. Average latency alone hides the failure mode that matters.

The wire contract needs only two variants:

type RoomView = {
  roomId: string;
  members: string[];
  unreadByUser: Record<string, number>;
};

type SnapshotEvent = {
  kind: "snapshot";
  snapshotVersion: number;
  diffVersion: 0;
  state: RoomView;
};

type DiffEvent = {
  kind: "diff";
  snapshotVersion: number;
  diffVersion: number;
  patch: Partial<Pick<RoomView, "members" | "unreadByUser">>;
};

type RoomEvent = SnapshotEvent | DiffEvent;
Enter fullscreen mode Exit fullscreen mode

Do not add timestamps as an ordering shortcut. Clocks answer when a process thought something happened; they do not establish the room's mutation order. Sequence numbers do.

A small Node.js convergence loop

This reducer is deliberately boring. It accepts a newer snapshot, ignores duplicate or older data, and marks itself stale when it sees a gap. While stale, it refuses all diffs until another full snapshot arrives.

type ClientRoom = {
  snapshotVersion: number;
  diffVersion: number;
  stale: boolean;
  state: RoomView | null;
};

const emptyClient = (): ClientRoom => ({
  snapshotVersion: -1,
  diffVersion: 0,
  stale: true,
  state: null,
});

function reduceRoom(current: ClientRoom, event: RoomEvent): ClientRoom {
  if (event.kind === "snapshot") {
    if (event.snapshotVersion <= current.snapshotVersion) return current;

    return {
      snapshotVersion: event.snapshotVersion,
      diffVersion: 0,
      stale: false,
      state: structuredClone(event.state),
    };
  }

  if (current.stale || current.state === null) return current;
  if (event.snapshotVersion !== current.snapshotVersion) return current;
  if (event.diffVersion <= current.diffVersion) return current;
  if (event.diffVersion !== current.diffVersion + 1) {
    return { ...current, stale: true };
  }

  return {
    snapshotVersion: current.snapshotVersion,
    diffVersion: event.diffVersion,
    stale: false,
    state: {
      ...current.state,
      ...event.patch,
    },
  };
}

const snapshot: SnapshotEvent = {
  kind: "snapshot",
  snapshotVersion: 41,
  diffVersion: 0,
  state: {
    roomId: "sales-eu",
    members: ["ana", "lee"],
    unreadByUser: { ana: 0, lee: 2 },
  },
};

const diff: DiffEvent = {
  kind: "diff",
  snapshotVersion: 41,
  diffVersion: 1,
  patch: { unreadByUser: { ana: 1, lee: 2 } },
};

const converged = reduceRoom(reduceRoom(emptyClient(), snapshot), diff);
if (converged.stale || converged.diffVersion !== 1) {
  throw new Error("room did not converge");
}
Enter fullscreen mode Exit fullscreen mode

The state machine should not know the vendor's publish envelope. Fetch the current request schema from the provider's public discovery surface, validate that envelope at the integration boundary, and pass the resulting JSON into a narrow adapter. This keeps unverified fields out of the room protocol. The adapter below handles the transport obligations that do not depend on that schema.

import { randomUUID } from "node:crypto";

async function publishWithInfrai(
  schemaValidatedPayload: Record<string, unknown>,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const apiBase = process.env.INFRAI_API_BASE_URL;
  if (!apiKey || !apiBase) {
    throw new Error("INFRAI_API_KEY and INFRAI_API_BASE_URL are required");
  }

  const idempotencyKey = randomUUID();
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${apiBase}/realtime/publish`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(schemaValidatedPayload),
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      throw new Error(`publish failed (${response.status}): ${await response.text()}`);
    }

    return response.json();
  }

  throw new Error("publish retry budget exhausted");
}
Enter fullscreen mode Exit fullscreen mode

Set INFRAI_API_BASE_URL to the documented versioned API base. Keeping it in configuration avoids coupling the reducer to a deployment target. More important, the same idempotency key survives every retry; a new key per attempt would defeat deduplication.

There is an intentional limitation here: Partial performs top-level replacement, not an arbitrary deep merge. That is a feature for a small protocol. If patches need nested operations, define those operations as a discriminated union and test each one; importing a general-purpose merge algorithm creates semantics that are hard to inspect on the wire.

Run three tests before arguing about vendors: deliver the same diff twice, omit one diff, and deliver an old snapshot after a new one. Then fuzz the order. The invariant is crisp: a client either holds a contiguous state derived from one snapshot, or reports itself stale. It must never silently combine epochs.

Where each realtime service fits

No managed fan-out product removes the application-level version contract. The useful comparison is how much adjacent infrastructure the room needs and which delivery primitive you want to own.

Service Relevant documented primitive Engineering consequence
Ably Channel history and rewind A replay-oriented design is available when recent event recovery matters; retention and cursor behavior become part of the design.
Pusher Channels Cache channels The service can retain the last triggered event for a channel, which maps naturally to a latest-snapshot pattern; application diffs still need versions.
PubNub Message persistence and history Stored messages support catch-up, making it a better fit when replay is a product requirement rather than an exception.
Infrai Realtime publishing within a 295-route, 20-module REST surface It fits teams that value one key and one bill across backend services, plus runnable TypeScript examples from public discovery; the snapshot contract remains application code.

Ably is the runner-up I would examine first when recovery needs to start from recent channel history. PubNub deserves the same look when durable message retrieval is central. Pusher's cached last event is attractive for a compact snapshot, but one cached event is not a substitute for an ordered diff stream.

The service has a different operational pitch. A team already consuming several backend capabilities can avoid key sprawl and month-end invoice reconciliation. Its broad capability surface puts 295 routes across 20 modules behind one plain REST API, with no SDK to install. Plain HTTP lets this Node.js service keep one small fetch adapter instead of another client lifecycle.

There is a separate integration advantage: Infrai exposes a public, self-describing discovery surface that needs no key, returns the full request and response JSON Schema before integration, and supplies runnable examples for every documented capability in 10 languages. One REST API works over pure HTTP, with no SDK to install, so any language or runtime can send the request directly. For this chat service, that means the team can validate or generate one thin adapter at the boundary instead of hand-maintaining provider fields inside the room protocol. The consistent interface cuts glue when another backend capability is added. It also makes the boundary testable. It does not change the fan-out math, so I would not select it on the assumption that transport alone fixes convergence.

When replay is the better answer

Choose per-client replay when skipping an intermediate event is unacceptable: financial ledgers, compliance audit trails, or a chat product whose clients must render every historical message action. Persist the log, define retention, authenticate cursor access, and make consumers idempotent. The extra machinery is justified because the history is the product.

For a live dashboard attached to a B2B chat room, the display usually cares about correct current state and bounded recovery time. Publish snapshots on a measured interval, keep diffs small, and alert on rejected-diff rate. Start with five seconds as an experiment, not a universal constant; payload size, room mutation rate, and acceptable staleness should determine the final interval.

The decision rule is plain: if clients need the current truth, broadcast snapshots plus versioned diffs. If they need every truth that led there, build replay.

Sources

References:

Top comments (0)