Short answer: keep a durable event cursor behind both WebSocket and Server-Sent Events, and issue a scoped room token only after the presence state is current. Transport fallback is useful only when every path preserves ordering, freshness, and an explicit recovery point.
| Option | Pick this when | The cost you accept |
|---|---|---|
| WebSocket | Dispatchers need a live room plus interactive controls | Proxy idle timeouts and bidirectional connection limits need active monitoring |
| Server-Sent Events (SSE) | The browser mostly receives status and alert events | Commands travel over separate HTTPS requests, so there are two request paths to secure |
| Snapshot plus event cursor | A reconnect must prove which driver and room states were applied | Replay storage, cursor expiry, and snapshot generation become operational work |
| Short polling | Freshness of a minute or more is acceptable | Lower connection complexity, but presence changes arrive in batches |
This is the decision table I use for a logistics control room. A dispatcher opens a video room for a delayed truck, then watches the driver and escort join. A green “connected” icon is not enough. The useful fact is: “driver-184 was confirmed present at sequence 9182, 14:03:18Z.”
Why does realtime transport fallback need a presence contract?
A TCP connection proves that bytes moved. It does not prove that the operator has the newest room membership. A sleeping browser tab, a radio handoff, a stalled proxy, or a busy event loop can leave a socket open while the displayed presence is stale.
Define presence as data with a freshness budget. Each event gets an immutable ID, a monotonically increasing sequence, an entity key, and a server timestamp. The reducer records the last sequence it actually applied. Heartbeats remain liveness hints; they never certify that a driver is still in the video room.
Keep it explicit.
The contract should cover three states: current, stale, and unknown. If the age exceeds the budget, show stale even if the transport says connected. If a cursor gap appears, show unknown until a snapshot or replay closes it. This small vocabulary prevents a familiar incident mistake: treating a healthy network path as proof of healthy business state.
Fifteen seconds might be fine for a parked trailer and unacceptable for an emergency vehicle. Your mileage may vary; measure the dispatch workflow and write the budget down before tuning reconnect timers.
How can a Node.js dashboard scale realtime event delivery with transport fallback?
Think in a short pipeline: producer -> durable log -> fan-out gateway -> browser reducer. The video-room service writes member_joined, member_left, and token-scope changes to the log. The gateway reads that log once, then adapts the same ordered records to WebSocket or SSE. The browser applies records and advances its cursor only after the reducer succeeds.
Here is the core sync shape. It has no vendor-specific assumptions, so the log can be backed by a database table, a queue, or an append-only stream.
type EventRecord = {
seq: number;
entityId: string;
kind: 'presence' | 'room' | 'alert';
payload: Record<string, unknown>;
at: string;
};
interface EventLog {
readAfter(cursor: number, limit: number): Promise<EventRecord[]>;
latest(): Promise<number>;
}
async function sync(
log: EventLog,
cursor: number,
send: (event: EventRecord) => void,
): Promise<number> {
const batch = await log.readAfter(cursor, 500);
for (const event of batch) send(event);
return batch.at(-1)?.seq ?? cursor;
}
On connect, the browser sends its last applied sequence. The gateway returns a bounded replay, or a snapshot with a new cursor when the requested sequence has expired. Replay first, then live delivery. Never acknowledge a message before the reducer applies it; otherwise a refresh can silently lose the join event that should have unlocked the room view.
The recovery path deserves a concrete timeline. At 14:03:18Z, the log contains sequence 9182 for driver-184 joining room-77. At 14:03:20Z, the browser loses Wi-Fi while the gateway has already sent 9182, so the reducer stores 9182 only after updating its local member set. At 14:03:31Z, the browser reconnects with cursor 9182; the gateway can safely request events after that point. If the next record is 9184, the missing 9183 is a proof of uncertainty, not permission to guess. The client marks the room presence unknown, asks for a snapshot, applies the snapshot cursor, and then accepts live records. During this exchange, the video UI can remain open with controls disabled, while the dispatch list clearly says “syncing.” That is a better operational outcome than showing an old member as present, issuing a token for the wrong role, and discovering the mismatch only when the dispatcher tries to speak.
That distinction matters.
For WebSocket, send a compact heartbeat and close connections that miss the server deadline. For SSE, emit an id field on every event and honor the browser’s Last-Event-ID. Both transports need admission control: cap connections per operator, cap replay size, and add jitter to reconnect delays. A gateway restart should not turn 2,000 browsers into one synchronized thundering herd.
I once started with a five-second reconnect loop because it looked friendly in a test. During a simulated regional flap, clients reconnected in the same five-second phase, connection attempts spiked, and alert delivery slowed. The repair was deliberately plain: exponential backoff, a random spread, and a stale-state banner that remained visible until a cursor was confirmed. The banner changed operator behavior; people stopped calling a silent map “live.”
Scoped tokens belong in this protocol too. Mint a token for one room and one role, attach an expiry, and log only a token fingerprint. A token grant is an event; revocation is another event. If the fallback path can deliver presence but cannot safely refresh authorization, pause room entry and request a new scoped token over HTTPS. Do not let a reconnect turn a room-scoped credential into a dashboard-wide credential.
What should observability measure before event delivery is called healthy?
Use the same sequence vocabulary in metrics, logs, and dashboards. Track publish-to-fan-out latency, replay depth, reducer lag, heartbeat age, token refresh latency, and the count of entities past their freshness budget. A transport-level “connected” gauge belongs beside an application-level “fresh presence” gauge.
For each reconnect, record a correlation ID, last client cursor, selected transport, replay count, snapshot reason, and room ID hash. Never put scoped tokens or raw driver identifiers in routine logs. During an incident, those fields answer a precise question: did the browser miss events, or did the producer never write them?
Alert on symptoms operators feel. Page when the 95th-percentile presence age exceeds its budget for five minutes, when replay requests consume a fixed fraction of live publishes, or when token refresh failures cluster by region. A single dropped socket is noise. A growing cursor gap is a signal.
Test the ugly transitions: sleep a tab, revoke a token, pause a log consumer, duplicate an event, deliver events out of order, and restart the gateway with clients connected. Property-based tests can assert that applying a snapshot followed by events produces the same state as applying those events continuously.
Limits: when is fallback the wrong tool?
The catch is operational weight. Dual transports mean two connection metrics, two authentication paths, and more browser states. SSE is a poor fit when dispatchers send frequent interactive commands over the same channel. WebSocket is a poor fit when proxy idle timeouts cannot be made predictable.
Snapshot-plus-log also has a retention limit. If a cursor is older than the retained window, replay is impossible; return a fresh snapshot and mark that transition explicitly. Do not manufacture continuity.
Stick with polling when minute-scale freshness is acceptable or event volume is too low to justify a fan-out gateway. A managed realtime service can be reasonable when its ordering, replay, token scoping, and regional failure behavior pass the same tests. I’m not sure any default survives every fleet topology, so keep those tests beside the deployment rather than trusting a feature checklist.
The practical rule is simple: presence is current only when the reducer can name the sequence that made it current. Everything else is a hint, and hints should be labeled as such.
Top comments (0)