Short answer: use one event log behind two transports, keep WebSocket as the normal path, and fall back to Server-Sent Events or a bounded poll when fan-out delivery cannot be trusted. The dashboard should replay from a cursor after every switch; a transport change must never become an acknowledgement that an incident update was seen.
I care about this because an incident response dashboard is a bad place for an elegant demo. A clinician may be watching a medication-service alert while dozens of responders open the same room. The important question is not whether a socket connected. It is whether each authorized viewer can account for every event, in order, after load or a broken network.
What changes when fan-out is the constraint?
Fan-out turns a small realtime feature into a delivery system. One alert can have hundreds of subscribers, and each subscriber has a different connection age, cursor, and permission set. A broadcast loop that only pushes the newest value hides the dangerous case: a client that silently missed the state transition between two renders.
I model the server around an append-only event stream with a monotonically increasing cursor per incident room. The stream is the source of truth. WebSocket, SSE, and polling are readers. A reader may be slow; it must not rewrite history for everyone else.
This also gives operators a useful invariant: for a cursor range, the number of acknowledged deliveries cannot exceed the number of events in that range. It is a boring invariant. Boring is good during an incident.
No exceptions.
The failure trace I want on paper is concrete: an alert enters the room at cursor 481, 60 browsers receive it, 12 sockets close during a deploy, and three of those clients reconnect through SSE while the rest return over WebSocket. The server does not invent a second copy of the alert for each path. Each client asks for events after its own cursor, applies 481 once, and advances its local position only after the handler accepts the payload. That sequence makes a transport handoff visible in metrics without turning it into a data-integrity problem.
How should realtime transport fallback scale event delivery in an incident response dashboard?
Treat fallback as a state machine, not a retry loop. The client starts with WebSocket, records the last cursor it applied, and moves to SSE after a bounded number of failed handshakes. If the stream still cannot be opened, a short poll asks for events after that cursor. Every response carries the next cursor, even when the list is empty.
Here is the smallest useful client shape. The endpoint names are intentionally generic; the contract matters more than the framework.
type Event = { cursor: number; type: string; payload: unknown };
type Batch = { events: Event[]; nextCursor: number };
async function readAfter(roomId: string, cursor: number): Promise<Batch> {
const url = `/incidents/${encodeURIComponent(roomId)}/events?after=${cursor}`;
const response = await fetch(url, { headers: { accept: "application/json" } });
if (!response.ok) throw new Error(`event read failed: ${response.status}`);
return response.json() as Promise<Batch>;
}
export async function followRoom(roomId: string, apply: (event: Event) => void) {
let cursor = 0;
let failures = 0;
while (true) {
try {
const socket = new WebSocket(`/incidents/${roomId}/stream`);
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve();
socket.onerror = () => reject(new Error("socket unavailable"));
});
failures = 0;
await new Promise<void>((resolve) => {
socket.onmessage = (message) => {
const event = JSON.parse(message.data) as Event;
if (event.cursor > cursor) {
apply(event);
cursor = event.cursor;
}
};
socket.onclose = () => resolve();
});
} catch {
failures += 1;
}
const batch = await readAfter(roomId, cursor);
for (const event of batch.events) {
if (event.cursor > cursor) apply(event);
}
cursor = Math.max(cursor, batch.nextCursor);
await new Promise((resolve) => setTimeout(resolve, Math.min(1000 * failures, 10000)));
}
}
The production version needs authentication, cancellation, jitter, and a maximum replay window. Those are not decorations. They stop a reconnect storm from becoming a second incident. The server should also cap batch size and expose a metric for the oldest cursor still available.
I keep the decision visible in the runbook because each fallback has a different operational shape:
| Path | Best use | Main cost |
|---|---|---|
| WebSocket | Low-latency bidirectional control | Connection fan-out and heartbeat work |
| SSE | One-way updates through a constrained network | Reconnect and browser connection limits |
| Bounded poll | Recovery and simple clients | More request overhead and slower freshness |
The table is not a ranking. It is a reminder that every transport still reads the same cursor range.
Which guarantees belong to the event log?
Transport protocols do not give the dashboard its business guarantee. The log and consumer rules do. Persist an event before advertising its cursor. Make handlers idempotent, keyed by (roomId, cursor), because a replay is expected behavior after a handoff. Do not infer “delivered” from a TCP close or a browser callback.
For a healthtech dashboard, authorization is checked again during replay. A responder can leave an incident while offline, and a reconnect must not grant access to old events. Redact payloads at the read boundary, too; retaining an event does not mean every role may read every field.
I would test this with a deterministic trace: publish 10,000 events, pause selected consumers, kill connections at random cursors, then compare each consumer's applied cursor set with the authorized stream. The useful output is a gap report, not a pretty latency chart.
I've also learned to put a clock on the test. A reconnect that takes 10 seconds can be acceptable for a low-priority audit panel and unacceptable for an active patient-safety alert. Your mileage may vary; the service-level objective and the incident policy should decide that threshold, not a transport default.
What should change at scale, and what is the catch?
At larger fan-out, shard rooms by a stable incident key and keep subscription metadata separate from event storage. Backpressure belongs per subscriber. A slow browser should receive a replay batch or be asked to refresh; it should not hold a shared broadcast worker hostage.
The catch is memory and retention. A generous replay window makes recovery easier but increases storage and privacy exposure. A tiny window lowers those costs but forces a full snapshot path when a client falls too far behind. Pick the window from measured reconnect distributions, then rehearse the snapshot path under load.
Fallback is also not suitable when the UI needs sub-second collaborative media or peer-to-peer audio. In that case, evaluate a media-oriented standard separately; the W3C WebRTC Recommendation describes the browser primitives for that class of connection. For an incident event feed, ordered replay and access checks are usually the sharper boundary.
Use a simpler poll-only reader when the dashboard is small, updates are infrequent, or operational simplicity outweighs immediacy. Keep the same cursor contract so changing the transport later does not change the data model.
Top comments (0)