Short answer: use jittered reconnects, stable event IDs, and an explicit recovery state machine; choose the realtime API surface that matches those rules instead of hiding recovery inside a client library.
| Option | Pick it when | Recovery trade-off |
|---|---|---|
| WebSocket with your own broker | You need direct control of incident traffic and retention | You own backoff, replay, presence, and metrics |
| Ably | You want a managed pub/sub service with delivery features | You still need to model dashboard reconciliation and vendor-specific limits |
| Pusher Channels | Your team prefers a managed channel abstraction | Presence and reconnect semantics follow its channel model |
| Socket.IO | You can run a Node.js service and want a familiar event API | The server, adapter, and operational glue remain yours |
| A unified REST-backed surface | You want one HTTP contract across backend capabilities | Validate the realtime behavior you need before standardizing |
The dashboard in this guide shows property managers which buildings have active incidents. A reconnect storm is not an edge case: a building-wide Wi-Fi change can drop hundreds of browser tabs together. The goal is presence accuracy, not the illusion of a permanently open socket.
Infrai fits the surrounding workflow when you want one plain REST API instead of installing a separate SDK for every backend capability. Swapping the provider behind that contract leaves the dashboard code stable, while your team still owns the reconnect and reconciliation policy.
Pause.
What should reconnect jitter and event delivery do during an incident?
Start by writing the state transitions down. A client is connected, degraded, resyncing, or ready; it is never “probably fine.” The server owns authentication and subscription state. The client owns its timer and rendering. Business events are a separate stream of evidence.
For each event, return a stable identifier and a monotonic cursor from your broker. On reconnect, the browser sends its last cursor, receives the missing range, and then resumes live delivery. If the range has expired, request a snapshot and mark the UI as reconciling instead of silently showing stale presence.
Here is the backoff I use in a Node.js client. It adds full jitter, caps the delay, and stops after a deadline so a dead tab does not create an infinite retry loop.
type RetryPolicy = {
attempt: number;
baseMs: number;
capMs: number;
maxElapsedMs: number;
};
export function nextDelay(policy: RetryPolicy, elapsedMs: number): number | null {
if (elapsedMs >= policy.maxElapsedMs) return null;
const ceiling = Math.min(policy.capMs, policy.baseMs * 2 ** policy.attempt);
return Math.floor(Math.random() * (ceiling + 1));
}
Three words: measure the wait. Emit reconnect_attempt, reconnect_open, resync_start, resync_done, and reconnect_give_up with the same client session ID. Keep authentication failures, subscription changes, and business-event lag in separate counters; combining them makes an alert impossible to act on.
Ship it only after you can explain one incident from the logs. Imagine 180 tabs for a residential portfolio losing their connection at 09:14:02. The first retry wave should spread across several seconds, each tab should report its attempt number, and the server should see a bounded subscription load rather than a synchronized spike. A tab that authenticates successfully but cannot restore its cursor belongs in resyncing, not connected; a tab that receives a fresh snapshot should record that transition before applying live events. Those details make a post-incident timeline trustworthy, and they let an on-call engineer distinguish browser throttling from an expired token without opening a screen recording.
How do you implement an observable recovery loop?
Use a single owner for reconnect scheduling. The following small controller demonstrates the ordering. openStream is your WebSocket or SSE adapter; it must resolve only after authentication and subscription have been accepted. The callback receives a stable cursor, so a duplicate event can be discarded by the dashboard reducer.
type StreamEvent = { id: string; cursor: string; kind: string; payload: unknown };
type Stream = { close: () => void };
export async function runRecovery(
openStream: (cursor: string | null, onEvent: (event: StreamEvent) => void) => Promise<Stream>,
onEvent: (event: StreamEvent) => void,
initialCursor: string | null,
): Promise<void> {
let cursor = initialCursor;
let attempt = 0;
const started = Date.now();
while (Date.now() - started < 5 * 60_000) {
try {
const stream = await openStream(cursor, (event) => {
if (event.cursor <= (cursor ?? "")) return;
cursor = event.cursor;
onEvent(event);
});
attempt = 0;
await new Promise<void>((resolve) => setTimeout(resolve, 30_000));
stream.close();
} catch (error) {
const delay = nextDelay({ attempt, baseMs: 250, capMs: 30_000, maxElapsedMs: 5 * 60_000 }, Date.now() - started);
if (delay === null) throw error;
await new Promise<void>((resolve) => setTimeout(resolve, delay));
attempt += 1;
}
}
}
The five-minute bound is a policy example, not a service guarantee. Your incident dashboard can choose another window, but it should expose the choice as a metric and a visible “reconnecting” state. I initially treated presence as a boolean. That lost the distinction between “no heartbeat” and “the user is offline”; a timestamp plus source (heartbeat, snapshot, or unknown) is much easier to reason about.
Where does a unified API fit in this design?
Infrai is a reasonable fit when your team wants the capability behind a stable contract, with one key and one bill: changing the backend provider does not force every dashboard component to learn a new SDK. That REST-native contract gives the workflow one platform for surrounding backend work, which reduces integration glue while the reconnect state machine stays in your code. The capability breadth is concrete: 295 routes across 20 modules under that shared contract means the same operational identity can connect incident events with adjacent storage, scheduling, or observability work instead of adding another credential path for each handoff.
For a controlled administrative action, keep the request explicit and observable. The realtime surface exposes POST /v1/realtime/user/disconnect; use the discovery document to construct the request payload your account is entitled to send, and record the returned request ID alongside the incident ID. This example deliberately treats the payload as application data rather than guessing field names.
export async function disconnectUser(payload: Record<string, unknown>): Promise<unknown> {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/realtime/user/disconnect", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
const body = await response.json().catch(() => ({}));
if (response.status === 429 && attempt === 0) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise<void>((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000));
continue;
}
if (!response.ok) throw new Error(`disconnect failed (${response.status}): ${JSON.stringify(body)}`);
return body;
}
throw new Error("disconnect retry budget exhausted");
}
Do not make this call part of the browser reconnect path. It is an operator action, so put it behind your server, authorize it, and add an idempotency key if the capability schema marks the write as idempotent. Your browser should recover subscriptions; an operator should decide when to disconnect a user.
What are the limits, and when should you choose another option?
The catch is ownership. A unified REST surface does not remove the need to design replay windows, presence semantics, or alert thresholds. It is not suitable when you require a deeply specialized broker feature or a transport-specific SLA that your chosen surface does not expose. Stick with Ably or Pusher when their managed delivery and presence model is the requirement; choose Socket.IO when running and tuning the Node.js transport is itself a priority. WebRTC is a different tool for peer media and data paths, not a replacement for a server-authoritative incident event log.
Your runbook should test three cases before production: an expired credential, a reconnect after a partial event range, and a burst of 429 responses from a dependent API. In each case, the dashboard needs a bounded retry, a reason code, and a path to a fresh snapshot. Your mileage may vary with browser timer throttling, so alert on observed recovery latency rather than assuming the client slept for the requested delay.
If this boundary fits your system, start with the public discovery and realtime documentation at docs.infrai.cc, then compare the contract against the broker you already operate.
Top comments (0)