../

Realtime fundamentals

The patterns every multiplayer app shares, whatever the library: rooms, fan-out, presence, message envelopes, ordering, acks, resync, trust and scaling. The socket itself (handshake, browser API, heartbeats, backpressure) is under WebSockets; applying these patterns in React is Realtime in React & Next.js, and in a library is Socket.IO or Durable Objects.

Transports

TransportShapeDeliveryPick it for
WebSocketclient ↔ server, one socketreliable, ordered (TCP)the default: chat, presence, boards, most games
SSE (EventSource)server → client over HTTPreliable, auto-reconnectfeeds, notifications, LLM streams; writes go over fetch
WebRTC data channelpeer ↔ peer (after signaling)reliable or not, ordered or not2–8 player games, voice/video sidecars, P2P file transfer
WebTransportclient ↔ server, HTTP/3streams + unreliable datagramsfast games and media where stale data is worthless
Long pollingrepeated HTTP requestsreliable, slowfallback only (Socket.IO does it for you)
  • Support, limits and head-of-line blocking: WebSockets: Comparing transports.
  • WebRTC still needs a server to exchange offers and ICE candidates (signaling, usually over a WebSocket) and a TURN relay for peers behind strict NATs. For unreliable, unordered delivery pass { ordered: false, maxRetransmits: 0 } to createDataChannel().
  • WebTransport is Baseline 2026 (newly available) and needs HTTPS plus an HTTP/3 server; keep a WebSocket fallback.
  • Server-to-client only? SSE plus plain POST for writes is simpler to host than any socket.

Rooms & channels

A room (Socket.IO), channel (Ably, Pusher, Supabase) or topic (Bun, MQTT) is a named group of connections that receive the same messages. The server owns membership; the client only asks.

ConceptWhat it isExample name
Connectionone socket; one tab, one deviceconn_8f2a
Userone identity; may hold several connectionsuser:42
Roomeveryone looking at one thingdoc:7b1c, match:9
Private roomone user's devices (DMs, notifications)user:42
Namespacea separate app on the same server/admin
  • Authorize on join (may user 42 see doc:7b1c?) and again on every write; membership is not permission.
  • Name rooms after the resource (doc:<id>), never after a user-typed label.
  • Rooms are cheap: one per document, match or conversation, plus one per user for direct messages.
  • Cap rooms per connection and connections per room; a single hot room is the usual scaling limit.

Pub/sub fan-out

A client sends one message; the server validates it, stamps it and fans it out to every connection in the room (usually excluding the sender, who already shows it).

Delivery guaranteeMeaningHow you get it
At most oncemay be lost, never duplicatedplain broadcast; Redis pub/sub
At least oncenever lost, may repeatacks + resend, a log (Redis Streams, DB)
Effectively onceat least once + dedupeclient-made id, server remembers it
  • In one process, fan-out is a loop or Bun's server.publish(topic, data).
  • Across processes, every node relays through a broker so users on other nodes hear it: see Scaling.
  • Persist first, then broadcast: a message that is shown but never stored vanishes on refresh.
  • Fan-out cost is messages × members: 1 message in a 1,000-member room is 1,000 sends. Coalesce high-rate updates (cursors, positions) to the latest value per tick instead of forwarding each one.

Presence

Presence answers "who is here right now" (and what they are doing: cursor, selection, typing).

RuleWhy
Track connections, report userstwo tabs must not make a user leave when one closes
Expire on silence (TTL), not only on closecrashed tabs and dead networks never send close
Send diffs (joined, left), not the full listfull lists are O(n²) traffic in big rooms
Full list once on jointhe newcomer needs the starting state
Throttle ephemeral state (cursors 20–30 Hz max)nobody sees 120 Hz; interpolate on the client
Never persist ephemeral statecursors and "typing…" are worthless after a reconnect
presence.ts
type Conn = { user: string; seen: number };
 
// Presence per user, tracked per connection: a user
// with two tabs open stays online until both close.
export class Presence {
  #conns = new Map<string, Conn>();
 
  #count(user: string): number {
    let n = 0;
    for (const c of this.#conns.values()) {
      if (c.user === user) n++;
    }
    return n;
  }
 
  // true when this is the user's first connection
  join(conn: string, user: string): boolean {
    this.#conns.set(conn, { user, seen: Date.now() });
    return this.#count(user) === 1;
  }
 
  // the user, when their last connection went away
  leave(conn: string): string | undefined {
    const c = this.#conns.get(conn);
    this.#conns.delete(conn);
    if (c && this.#count(c.user) === 0) return c.user;
  }
 
  beat(conn: string): void {
    const c = this.#conns.get(conn);
    if (c) this.#conns.set(conn, { ...c, seen: Date.now() });
  }
 
  // run on a timer: evict silent connections
  sweep(ttlMs: number): string[] {
    const cutoff = Date.now() - ttlMs;
    const gone: string[] = [];
    for (const [id, c] of this.#conns) {
      if (c.seen >= cutoff) continue;
      const user = this.leave(id);
      if (user) gone.push(user);
    }
    return gone;
  }
 
  online(): string[] {
    const all = [...this.#conns.values()];
    return [...new Set(all.map((c) => c.user))];
  }
}

Broadcast joined when join() returns true and left when leave() or sweep() returns a user. Across nodes, keep presence in Redis (a hash per room with per-connection expiry) or in the room's Durable Object.

Message envelopes

Every message shares one outer shape; the type field picks the payload. Start from WebSockets: Typed messages and add these fields:

FieldSet byPurpose
typesenderdiscriminant for the union; drives exhaustive switch
idclientUUID made once per logical message; idempotency and ack matching
roomclienttarget room; the server re-checks membership
seqserverper-room counter, 1, 2, 3…: total order and gap detection
tsserverserver clock in ms; display only, never for ordering
vsenderprotocol version; bump before the first breaking change
envelope.ts
import { z } from "zod";
 
const base = {
  id: z.uuid(), // made by the client: idempotency key
  room: z.string().min(1).max(64),
  v: z.literal(1).default(1), // protocol version
};
 
export const ClientMsg = z.discriminatedUnion("type", [
  z.object({ ...base, type: z.literal("join") }),
  z.object({
    ...base,
    type: z.literal("chat"),
    text: z.string().trim().min(1).max(2000),
  }),
  z.object({
    ...base,
    type: z.literal("resume"),
    since: z.int().nonnegative(), // last seq applied
  }),
]);
export type ClientMsg = z.infer<typeof ClientMsg>;
 
// the server stamps order (seq) and time (ts)
export type Body =
  | { type: "chat"; from: string; text: string }
  | { type: "left"; user: string };
export type RoomEvent = Body & {
  id: string;
  room: string;
  seq: number;
  ts: number;
};
export type Reply =
  | { type: "ack"; id: string; seq: number }
  | { type: "nack"; id: string; code: NackCode }
  | { type: "snapshot"; room: string; seq: number;
      state: unknown };
type NackCode = "invalid" | "forbidden" | "rate_limited";
 
export function parseClient(raw: string): ClientMsg | null {
  try {
    const r = ClientMsg.safeParse(JSON.parse(raw));
    return r.success ? r.data : null;
  } catch {
    return null; // not JSON
  }
}
  • The client never sends seq, ts or from: the server fills them from its own state (ws.data.userId).
  • Validate on the server always; on the client too if the server is not yours.

Ordering & idempotency

ProblemCauseFix
Out of orderseveral servers, retries, parallel requestsone writer per room assigns seq
Duplicatesresend after a lost ack, replay on resumeclient id; server keeps id → seq; client drops seq ≤ last
Gapsmissed events while offline or on a lagging nodedetect seq > last + 1, ask to resume
Clock skewclient clocks disagree by secondsorder by seq; ts is only a label
Two writers, one fieldconcurrent editslast-writer-wins by seq, or a CRDT: Collaboration

The client side of that contract: apply each event once, in seq order, and hold early arrivals until the gap fills.

inbox.ts
import type { RoomEvent } from "./envelope.ts";
 
// Applies each room event once, in seq order.
export function inbox(
  apply: (e: RoomEvent) => void,
  onGap: (last: number) => void, // send a resume
) {
  let last = 0; // highest seq applied
  const early = new Map<number, RoomEvent>();
 
  const drain = () => {
    let next = early.get(last + 1);
    while (next) {
      early.delete(next.seq);
      apply(next);
      last = next.seq;
      next = early.get(last + 1);
    }
  };
 
  return {
    get last() {
      return last;
    },
    reset(seq: number) { // after a snapshot
      last = seq;
      for (const n of early.keys()) {
        if (n <= seq) early.delete(n);
      }
      drain();
    },
    push(e: RoomEvent): void {
      if (e.seq <= last) return; // duplicate: drop
      early.set(e.seq, e);
      if (e.seq > last + 1) return onGap(last); // hole
      drain();
    },
  };
}

Debounce onGap: one hole can trigger it for every later event.

Optimistic updates & acks

Show the user's own action immediately, then let the server confirm or reject it.

client                         server
  | render "pending" (id=a1)     |
  |-- { type: chat, id: a1 } --> | validate, authorize, rate limit
  |                              | persist, seq = 58
  | <-- { type: ack, id: a1,     | broadcast event seq 58 to the room
  |       seq: 58 }              |
  | swap pending a1 → event 58   |
  |                              |
  | (or) <-- { type: nack,       | rejected: roll back, show why
  |       id: a1, code }         |
StateUILeaves it when
pendingdimmed, spinner or clock iconack (→ confirmed), nack (→ failed), timeout (→ retry)
confirmednormalnever
failedred, "Retry" / "Delete"user retries with the same id
  • Match the ack by id, not by content or position.
  • Your own broadcast echo also arrives (unless excluded): dedupe it by id against the pending list.
  • Keep the server as the source of truth: when the confirmed event differs (trimmed text, new seq), it wins.
  • React: useOptimistic; Socket.IO: acks.

Reconnection & resync

Reconnecting the socket is the easy half (backoff with jitter); the other half is catching up on what happened while you were gone.

StrategyClient sendsServer needsUse when
Refetch everythingnothing; GET the statenothing extrasmall state, rare disconnects
Replay since last seq{ type: "resume", since }a bounded log per room (ring buffer, Redis Stream)short gaps, chat, activity feeds
Snapshot + deltasresume; server decidesa snapshot at seq + the log after itlong gaps, game and doc state
Library recoveryautomaticSocket.IO connectionStateRecoverygaps under ~2 minutes
room-log.ts
import type { Body, RoomEvent } from "./envelope.ts";
 
// One per room: assigns seq, dedupes by id, keeps
// the last `max` events so reconnecting clients
// can catch up without a full snapshot.
export class RoomLog {
  #events: RoomEvent[] = [];
  #byId = new Map<string, number>(); // id -> seq
  #seq = 0;
 
  constructor(readonly room: string, readonly max = 500) {}
 
  get seq(): number {
    return this.#seq;
  }
 
  append(id: string, body: Body) {
    const dup = this.#byId.get(id);
    if (dup !== undefined) return { seq: dup, fresh: false };
    const seq = ++this.#seq;
    const event: RoomEvent = {
      ...body, id, room: this.room, seq, ts: Date.now(),
    };
    this.#events.push(event);
    this.#byId.set(id, seq);
    if (this.#events.length > this.max) {
      const old = this.#events.shift();
      if (old) this.#byId.delete(old.id);
    }
    return { seq, fresh: true, event };
  }
 
  // events after `since`, or null if they were trimmed
  since(since: number): RoomEvent[] | null {
    const first = this.#events[0]?.seq ?? this.#seq + 1;
    if (since + 1 < first) return null; // too old
    return this.#events.filter((e) => e.seq > since);
  }
}
resume.ts
import type { Reply } from "./envelope.ts";
import type { RoomLog } from "./room-log.ts";
 
type Conn = { send(data: string): unknown };
 
// on { type: "resume", since }: replay or snapshot
export function resume(
  conn: Conn,
  log: RoomLog,
  since: number,
  state: () => unknown, // current room state
): void {
  const missed = log.since(since);
  if (missed) {
    for (const e of missed) conn.send(JSON.stringify(e));
    return;
  }
  const snap: Reply = {
    type: "snapshot",
    room: log.room,
    seq: log.seq, // client calls inbox.reset(seq)
    state: state(),
  };
  conn.send(JSON.stringify(snap));
}
  • On reconnect, in order: re-authenticate, rejoin rooms, resume each room, then resend pending writes.
  • A snapshot must be taken at exactly log.seq, or events are applied twice or skipped.
  • A seq counter kept only in memory resets when the process restarts: persist it with the room, or add an epoch ({ epoch, seq }) and force a snapshot when the epoch changes.

Authority & trust

ModelWho decidesGood forCost
Authoritative serverserver validates and applies every actionchat, games with stakes, anything with money or rankslatency: hide it with prediction
Relay serverserver forwards, clients decidecursors, low-stakes toystrivial to cheat or corrupt
Peer-to-peer (WebRTC)one peer hosts, or lockstepsmall co-op games, LAN-like playhost can cheat; NAT and TURN pain
CRDTnobody: replicas merge by mathdocs, canvases, offline-firstbigger payloads; permissions still need a server

Trust rules for any server that fans out:

  • Validate every message with a schema and authorize every action (join, write, kick) against the room.
  • Identity comes from the connection (ws.data.userId, socket.data), never from a field in the message.
  • Rate limit per connection and per user (token bucket in WebSockets: Security); nack with rate_limited, then disconnect repeat offenders.
  • Cap sizes: message bytes, rooms per connection, members per room, events per second per room.
  • Clients send intent, not results: "move left", not "I am at x = 900"; "buy item 3", not "gold = 999".
  • Escape on render: realtime text is user input.
  • Games: Game worlds covers prediction, reconciliation and anti-cheat.

Scaling

One server holds every socket until it doesn't. The two common shapes:

Socket.IO + Redis adapter N app servers share one broker amy bob cy node A node B Redis pub/sub emit publish relay every node hears every room; sticky sessions if polling is on One Durable Object per room the room's sockets end at one object amy bob cy Durable Object "room:lobby" SQLite send broadcast no broker: one object orders the room; a room is capped at one object's speed Same room, same message: amy sends, bob and cy receive. Left scales by adding nodes; right scales by adding rooms.
Two ways to fan out one room: a broker between many servers, or one object per room
ApproachHow it worksWatch out for
Vertical firstone Bun process handles tens of thousands of idle socketsmemory per socket; one deploy drops everyone
Sticky sessionsthe load balancer pins a client to one node (cookie or IP hash)required for polling fallbacks and in-memory sessions
Broker fan-out (Redis pub/sub)every node publishes and relays; Socket.IO: Redis adapterfire-and-forget; every node receives every message
Broker with a log (Redis Streams)same, but replayable; Socket.IO: Redis Streams adapterstream trimming (maxLen) bounds recovery
One object per roomroute by room name to a single owner: a Durable Object, or a consistent-hash sharda hot room can't spread across machines
Hosted serviceAbly, Pusher, Supabase, Liveblocks run the fan-outper-message and per-connection pricing

Build vs buy

OptionWhat you getPricing model (checked September 2026)
Self-hosted (Bun, Socket.IO)full control, any protocolyour servers + Redis; you run scaling, deploys and on-call
Ablypub/sub channels, presence, history, ordering guaranteesfree: 6M messages/month, 200 connections; Standard from 29 USD/month + per message and per connection-minute
Pusher Channelspublic, private and presence channels; simple SDKsfree: 200k messages/day, 100 connections; tiers by connections + daily messages, from 49 USD/month
Supabase RealtimeBroadcast, Presence, Postgres change feedsFree: 2M messages, 200 peak connections; Pro: 5M and 500 included, then per million messages and per 1,000 peak connections
Liveblockspresence, conflict-free storage, Yjs, commentsfree: 3,000 collaboration minutes, 10 connections per room; Pro 30 USD/month in usage credits
PartyKit → CloudflarePartyKit joined Cloudflare (April 2024); PartyServer + partysocket on Durable ObjectsWorkers pricing: free tier, paid from 5 USD/month; incoming WebSocket messages billed 20:1; hibernated sockets cost no duration
  • Buy when realtime is a feature, not the product: notifications, a presence dot, a live counter.
  • Build when the protocol is the product (a game loop, a custom CRDT), or per-message pricing explodes at your message rate.
  • Middle path: Durable Objects or PartyServer, where you write the room logic and Cloudflare runs the fleet.
  • Prices change: confirm on each vendor's pricing page before committing.

Recipes

Idempotent write handler

When clients may resend: the same id always returns the same seq and never broadcasts twice.

import { parseClient, type Reply } from "./envelope.ts";
import { RoomLog } from "./room-log.ts";
 
type Conn = { userId: string; send(d: string): unknown };
const logs = new Map<string, RoomLog>();
 
export function onMessage(
  conn: Conn,
  raw: string,
  broadcast: (room: string, data: string) => void,
): void {
  const msg = parseClient(raw);
  if (!msg || msg.type !== "chat") return;
  let log = logs.get(msg.room);
  if (!log) {
    log = new RoomLog(msg.room);
    logs.set(msg.room, log);
  }
  const res = log.append(msg.id, {
    type: "chat",
    from: conn.userId, // from the socket, not the message
    text: msg.text,
  });
  const ack: Reply = {
    type: "ack", id: msg.id, seq: res.seq,
  };
  conn.send(JSON.stringify(ack)); // ack duplicates too
  if (res.event) {
    broadcast(msg.room, JSON.stringify(res.event));
  }
}

Resend until acked

When the network may drop the message or its ack; call ack(id) on each ack reply and resendAll() after reconnecting.

// Resend until acked, always with the same id, so a
// retry after a lost ack can't create a duplicate.
type Pending = { data: string; tries: number };
 
export function reliable(
  send: (data: string) => void,
  { timeoutMs = 5000, maxTries = 5 } = {},
) {
  const pending = new Map<string, Pending>();
  type T = ReturnType<typeof setTimeout>;
  const timers = new Map<string, T>();
 
  const attempt = (id: string) => {
    const p = pending.get(id);
    if (!p) return;
    if (p.tries >= maxTries) {
      pending.delete(id); // give up: show "not sent"
      return;
    }
    pending.set(id, { ...p, tries: p.tries + 1 });
    send(p.data);
    timers.set(id, setTimeout(() => attempt(id), timeoutMs));
  };
 
  return {
    send(id: string, msg: object): void {
      const data = JSON.stringify({ ...msg, id });
      pending.set(id, { data, tries: 0 });
      attempt(id);
    },
    ack(id: string): void { // on { type: "ack", id }
      clearTimeout(timers.get(id));
      timers.delete(id);
      pending.delete(id);
    },
    resendAll(): void { // call after reconnecting
      for (const id of pending.keys()) {
        clearTimeout(timers.get(id));
        attempt(id);
      }
    },
  };
}

Presence sweep with diffs

When the server should announce joins and leaves without waiting for close events that never come.

import { Presence } from "./presence.ts";
 
const TTL = 45_000; // > 2 client heartbeats
const rooms = new Map<string, Presence>();
 
export function startSweeper(
  broadcast: (room: string, data: string) => void,
): () => void {
  const timer = setInterval(() => {
    for (const [room, p] of rooms) {
      for (const user of p.sweep(TTL)) {
        const left = { type: "left", room, user };
        broadcast(room, JSON.stringify(left));
      }
    }
  }, 15_000);
  return () => clearInterval(timer);
}

Pick an architecture

When starting a new realtime feature and deciding what to run.

Only server → client updates?          → SSE + POST, or a hosted provider
A few rooms, one region, < ~10k sockets → one Bun process (Bun.serve topics)
Need acks, rooms, reconnect for free   → Socket.IO (+ Redis Streams adapter at 2+ nodes)
Many independent rooms, global users   → one Durable Object per room (PartyServer)
Shared documents or canvases           → Yjs over any of the above (see Collaboration)
Fast action game                       → authoritative server loop (see Game worlds)
Realtime is a small feature            → Ably, Pusher, Supabase Realtime, Liveblocks

References