Short answer: keep one server-owned poll snapshot, attach a monotonically increasing revision, and send later changes as diffs. A client that joins late asks for the current snapshot first, then applies diffs only when the revision is exactly the next one. This keeps a marketplace session's vote totals coherent and limits what an untrusted browser can assert.
The data flow is deliberately plain. A poll service owns the canonical question, options, counts, and revision. A connection receives a snapshot, then subscribes to an ordered stream of patches. The browser renders the snapshot immediately; it does not reconstruct history from local events. If a gap appears, it requests another snapshot. That is the least complex path I have found for a live dashboard where a late participant must see the full state.
Ship it.
How should Node.js send a snapshot plus diff to late joiners?
A snapshot is a complete state at revision r. A diff is a small, validated change from r to r + 1. The distinction matters during a busy marketplace session: a user may open the poll after several votes, while a reconnecting mobile client may have missed packets. Sending every historical vote wastes tokens and increases replay surface. Sending only the latest diff leaves the client with an empty chart.
The server should bind the poll to a session identifier and enforce the participant's permissions before returning either message. Client-supplied counts are never accepted. The client sends an option choice; the server decides whether it is allowed, updates its own state, increments the revision, and broadcasts the resulting patch.
Here is a small in-memory model suitable for an eval harness or a notebook-to-prod prototype. The wire format is explicit so the same contract can later sit behind WebSocket, Server-Sent Events, or a WebRTC data channel.
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class Poll:
question: str
options: list[str]
counts: Dict[str, int] = field(default_factory=dict)
revision: int = 0
def snapshot(self) -> dict:
return {"kind": "snapshot", "revision": self.revision, "question": self.question, "options": self.options, "counts": dict(self.counts)}
def record_vote(self, option: str, participant_id: str) -> Optional[dict]:
if option not in self.options:
raise ValueError("unknown option")
self.counts[option] = self.counts.get(option, 0) + 1
self.revision += 1
return {"kind": "diff", "from_revision": self.revision - 1, "to_revision": self.revision, "changes": {"counts": {option: self.counts[option]}}, "actor": "server"}
poll = Poll("Which delivery window works?", ["Morning", "Afternoon", "Evening"])
initial = poll.snapshot()
patch = poll.record_vote("Afternoon", "participant-42")
The participant_id is intentionally not echoed into the patch. Identity and authorization belong in the server's connection context, while the public event contains only the state change needed by the dashboard. In a real service, persist the revision with the update so two workers cannot publish conflicting numbers.
How do you recover from a revision gap?
Every consumer keeps last_revision. On a diff, it checks from_revision == last_revision. A match permits an atomic apply and advances the cursor. A mismatch means the stream is no longer sufficient; the consumer pauses rendering, requests a fresh snapshot, replaces its state, and resumes from that snapshot's revision. Do not guess, merge, or silently drop the event.
That recovery costs a round trip. It is the trade-off for a small event log and a trustworthy cursor.
This is also where operational behavior becomes visible. Measure snapshot latency, diff-to-snapshot recoveries, rejected votes, and the age of the oldest connected client. Alert on a rising gap rate rather than on raw message volume. For token-cost control, serialize only changed fields in diffs and keep snapshots bounded: option labels, counts, revision, and poll status.
A useful test matrix includes a clean join, a join between two votes, a duplicated diff, an out-of-order diff, and a reconnect after a permission change. I start with those cases in an eval harness before adding persistence or fan-out. The test should assert state and revision together; a visually correct chart with a stale cursor is still wrong.
For a marketplace session, I also test the awkward middle: the moderator closes voting while a late joiner receives its snapshot, a participant changes browsers, and two workers attempt the same revision. The expected result is deterministic: closed status is part of the snapshot, the second browser gets the same totals, and one transaction wins while the other retries against a newer revision. Writing these assertions before wiring transport keeps failures pointed at state ownership instead of timing in a callback, which shortens the debugging loop as a prototype grows into a service.
Which transport fits the contract?
WebSockets are a natural fit for bidirectional voting and server pushes. Server-Sent Events simplify one-way dashboard updates when votes arrive through another endpoint. WebRTC data channels can be appropriate for peer-oriented sessions, but signaling, authorization, and recovery still need a server-owned contract; the transport does not make client state trustworthy. The snapshot/diff rule stays the same across all three.
This pattern is a poor fit when the state is enormous or changes continuously at sub-second frequency: snapshots become expensive and resyncs can dominate bandwidth. In those cases, partition the state or use a specialized replicated log, accepting more operational machinery.
Keep the implementation checklist in prose: authenticate the session before snapshot access, make revisions durable, apply diffs atomically, cap snapshot size, define a resync message, and record enough telemetry to explain a gap. Then rehearse a worker restart and a reconnect while the poll is active. If the late joiner can reach a complete snapshot and converge without manual refresh, the design is doing its job.
Top comments (0)