DEV Community

TateFletcher6754
TateFletcher6754

Posted on

Fan-Out Guarantees for Browser Reconnects in Team Presence and Live Polls

Short answer: use a server snapshot plus a monotonic cursor for the team presence sidebar, and use idempotent commands with explicit acknowledgements for live poll votes. A browser reconnect should converge the sidebar to current truth before it resumes incremental updates; it should never pretend that every transient presence event deserves durable delivery.

Picture a logistics dispatch session. Thirty operators can see who is online, and the facilitator opens a live poll to choose the next loading window. Both features fan out over the same realtime connection, but they do not have the same correctness requirement. Missing an intermediate away event is harmless once a fresh snapshot says the operator is online. Losing a submitted vote is not. That distinction drives the architecture.

Choose the delivery guarantee first

State or action Reconnect target Useful mechanism Main trade-off
Presence roster Converge to current state Versioned snapshot, then deltas Intermediate transitions may be discarded
Live poll tally Show an authoritative result Snapshot or replay from a retained cursor Retention and ordering add state on the server
Vote submission Apply once from the user's point of view Idempotency key plus acknowledgement The server must remember processed command IDs
Typing or pointer hint Best effort Drop stale events and resume Some hints disappear during a disconnect

This table is the design, not documentation added after the code. Label each message by what a reconnect must guarantee. "Realtime" only describes when delivery is attempted; it doesn't define what happens after a laptop sleeps, a radio changes networks, or two connection attempts overlap.

The least complex presence design is snapshot convergence. Give every accepted state change a monotonic cursor within one stream. On initial connection and after any uncertain gap, send an authoritative roster with the cursor that produced it. The client installs that snapshot atomically, discards deltas at or below the snapshot cursor, and then accepts newer deltas. Fast reconnects can request events after the last applied cursor, but the server may answer with another snapshot when that cursor has expired. Either response is correct because current presence, rather than the path taken to reach it, is the product requirement.

A poll vote crosses a harder boundary. The UI may retry after losing the connection before it receives an acknowledgement, so the same command can arrive twice. Attach a stable command ID generated once per user action. The server records that ID with the accepted vote and returns the same outcome on a retry. Do not generate a new ID inside the retry loop.

Retry IDs don't rotate.

Pick each option when its failure mode matches the feature

Pick snapshot convergence when only current truth matters. Presence, availability, and active-session membership usually fit this model. It limits replay storage, keeps recovery bounded by snapshot size, and gives the client one clean reset point. The catch is that it cannot reconstruct every transition for an audit trail. If compliance requires the exact sequence of status changes, retain those changes in a separate durable record and treat the sidebar as a projection of that record.

Pick ordered replay when every accepted event matters and the stream has a clear ordering domain. A poll tally can replay accepted vote changes after a cursor, provided the server defines whether ordering is per poll, per participant, or global. Per-poll ordering is often enough; a global sequence couples unrelated sessions and can make one busy poll delay another. I'm not sure a replay window of 30 seconds, 10 minutes, or a full session is right for your traffic. Measure reconnect duration in production, then choose a window that covers the observed tail plus an explicit margin.

Pick command acknowledgement and idempotency when the client initiates a durable action. This is the vote path. An acknowledgement means the command reached the application boundary and was accepted or rejected; it is different from a transport-level indication that bytes were sent. Keep the pending command in client storage until that application result arrives. If the session expires, return a stable domain rejection such as POLL_CLOSED and let the UI stop retrying.

A direct peer-to-peer data channel is another serious option for small, tightly controlled sessions. WebRTC defines peer connections and data channels, but peer topology moves fan-out, membership, and recovery responsibility toward the clients. That can be appropriate for media-adjacent collaboration where peers already have an established connection. It is a poor default for an authoritative logistics roster or poll because the application still needs a trusted place to resolve membership, vote identity, and the final tally. Stick with server-mediated fan-out when one result must be authoritative.

How should browser reconnects preserve reliable updates in a team presence sidebar?

Use one reducer for both the initial snapshot and live deltas. That prevents the common split where reconnect code mutates state differently from steady-state code. The example below models the contract; the transport can carry these messages over a persistent browser connection without changing the reducer.

type MemberStatus = "online" | "away" | "offline";

type Member = {
  id: string;
  status: MemberStatus;
  updatedAt: string;
};

type PresenceMessage =
  | { kind: "snapshot"; cursor: number; members: Member[] }
  | { kind: "upsert"; cursor: number; member: Member }
  | { kind: "remove"; cursor: number; memberId: string };

type PresenceState = {
  cursor: number;
  members: Map<string, Member>;
  needsSnapshot: boolean;
};

function reducePresence(
  state: PresenceState,
  message: PresenceMessage,
): PresenceState {
  if (message.kind === "snapshot") {
    return {
      cursor: message.cursor,
      members: new Map(message.members.map((member) => [member.id, member])),
      needsSnapshot: false,
    };
  }

  if (state.needsSnapshot || message.cursor <= state.cursor) {
    return state;
  }

  if (message.cursor !== state.cursor + 1) {
    return { ...state, needsSnapshot: true };
  }

  const members = new Map(state.members);
  if (message.kind === "upsert") {
    members.set(message.member.id, message.member);
  } else {
    members.delete(message.memberId);
  }

  return { cursor: message.cursor, members, needsSnapshot: false };
}
Enter fullscreen mode Exit fullscreen mode

The gap check is deliberate. If cursor 1842 is followed by 1844, the reducer does not apply a plausible-looking update and hope for the best. It marks the projection uncertain and requests a snapshot. During that brief state, keep the old roster visible with a reconnecting indicator; do not flash an empty sidebar, because empty and unknown mean different things. Suppose operator dock-17 was shown as away at 1842, changed to online at the missing 1843, and another operator changed at 1844. Applying only 1844 would leave one believable but false row in the roster. The safer client freezes delta application, asks for a snapshot, and continues displaying the last coherent map as stale. If a delayed 1843 arrives while that request is in flight, it still does not clear the uncertainty: more messages may have been missed. A snapshot at 1850 replaces the entire map and establishes a new lower bound. Any delayed delta through 1850 is ignored; 1851 is the next acceptable incremental message. This is why the reconnect indicator belongs to the projection state rather than the socket state. A newly open socket can still be recovering data. Once the snapshot arrives, replace the map in one render.

Gaps are state.

Now handle the poll command separately. The same command ID survives reconnects and retries.

type VoteCommand = {
  kind: "cast_vote";
  commandId: string;
  pollId: string;
  optionId: string;
};

type VoteResult =
  | { kind: "vote_accepted"; commandId: string; tallyCursor: number }
  | {
      kind: "vote_rejected";
      commandId: string;
      reason: "POLL_CLOSED" | "NOT_ELIGIBLE";
    };

class PendingVotes {
  private readonly commands = new Map<string, VoteCommand>();

  add(command: VoteCommand): void {
    this.commands.set(command.commandId, command);
  }

  acknowledge(result: VoteResult): void {
    this.commands.delete(result.commandId);
  }

  afterReconnect(): VoteCommand[] {
    return [...this.commands.values()];
  }
}
Enter fullscreen mode Exit fullscreen mode

The server-side rule is equally important: processing commandId twice must produce one logical vote and the same recorded outcome. The tally broadcast may still arrive more than once. Reducers already ignore cursors they have applied, so duplicate fan-out is safe. This combination gives at-least-once command delivery with idempotent application instead of claiming an unrealistic end-to-end "exactly once" transport.

Diagram it in words: browser sends vote; command store records ID and outcome; poll projection advances its cursor; fan-out publishes the new tally; browser acknowledges the command and applies the tally. On reconnect, two arrows run independently. Pending commands go back toward the command store, while the last tally and presence cursors go toward recovery. Keeping those lanes separate makes logs much easier to read.

Instrument the boundaries, not every heartbeat. Emit a reconnect attempt count, recovery mode (replay or snapshot), cursor gap size, snapshot age, pending-command count, and time until the sidebar becomes trustworthy again. Correlate them with a session ID and connection attempt ID. Alert on sustained recovery failure or a growing pending-command age, not on a single reconnect; mobile and laptop browsers disconnect normally. For deployment, test mixed versions by having an older client reject unknown message kinds cleanly, then add the new producer only after the consumer is deployed.

The test that catches most mistakes is short: connect, accept cursor 41, disconnect, submit one vote, reconnect twice, receive duplicate cursor 42, then receive cursor 44 without 43. Assert one logical vote, one pending-command acknowledgement, and a requested snapshot rather than a silently corrupted roster. Add a second test where the snapshot at cursor 50 races with a delayed delta at 49; the final roster must remain at 50. No timing sleeps are needed when the transport is a controllable test double.

Know where this design stops

Snapshot-plus-cursor recovery is not suitable when consumers must reproduce every historical transition. Use a durable event log and define retention, partition ordering, and replay authorization for that case. Client-generated idempotency keys are also insufficient if two different authorized users can submit the same business action; the server then needs a business-level uniqueness rule in addition to command deduplication.

WebRTC data channels are not a shortcut around application correctness. The W3C recommendation specifies the peer connection model, while roster authority, reconnect policy, and vote semantics remain application decisions. Choose it when peer communication is already central to the session and the extra topology work is justified. Choose server fan-out when operational visibility and one authoritative session state matter more.

There is no universal delivery guarantee. Name the state, name the tolerated loss, and name the recovery source. Then the reconnect path becomes ordinary state synchronization rather than a collection of socket callbacks.

References

Further reading

Top comments (0)