Short answer: for a team presence sidebar, choose a realtime service only after proving that a reconnect can resume from a stable cursor, reject duplicate updates, and reconcile against current state; a fast live connection without an explicit recovery contract is the wrong optimization.
An e-commerce operations sidebar looks harmless until one browser sleeps while 40 agents sign in, switch queues, and leave. On wake, the client must answer a harder question than "am I connected?" It must decide which updates it missed and whether a replayed event is newer than the state already on screen. I care about time-to-first-call, but delivery behavior at fan-out wins this decision. Config can wait. Correctness can't.
How should realtime browser reconnects recover team presence sidebar updates?
Split the job into three contracts: transport, recovery, and state. The transport carries live events. Recovery obtains everything after a client-held cursor. State reconciliation applies an update only when its stable event identifier has not already been seen and its entity version is newer. This separation keeps a reconnect from masquerading as a fresh subscription.
The client owns its last acknowledged cursor, a bounded set of recently seen event IDs, and the visible member map. The server owns authorization, monotonically ordered recovery within the promised scope, and a way to obtain current authoritative state. Authentication state, subscription state, and business events need separate telemetry. Otherwise a revoked token, a dropped subscription, and an empty team all look like the same blank sidebar.
Recovery is the product.
The exact scope matters. A global cursor is easy to explain but can become expensive at high fan-out. A per-team cursor limits replay work, while a per-member cursor creates bookkeeping bloat. For this sidebar I would start with one cursor per team because the UI consumes the team as a unit. Your mileage may vary if users can watch hundreds of teams at once; the evidence needed is a fan-out test with the real subscription distribution, not a synthetic single-channel loop.
The constraint that changes the choice
Presence is soft state, but the updates still need hard identities. Suppose event evt_1042 marks agent usr_17 as busy at version 81. The browser receives it, updates the UI, and loses the connection before acknowledging the cursor. Recovery may deliver evt_1042 again. That is acceptable. Applying an older version 80 after it is not.
Duplicates are normal inputs here.
I benchmark the recovery path separately from the happy path: disconnect during a burst, delay one event, deliver another twice, reconnect with an expired credential, and verify that the resulting member map matches an authoritative snapshot. I don't turn those runs into fake uptime or latency claims. Their job is binary: expose a broken state transition before a user sees a phantom agent. A useful test matrix includes realistic latency, duplicate delivery, authorization denial, cursor expiry, and two tabs recovering the same account independently.
This also changes what "reliable" means. Exactly-once delivery is a seductive label, but the UI needs exactly-once effects. Stable IDs plus version-aware reconciliation provide that property even when the transport redelivers. If a vendor cannot explain cursor lifetime, ordering scope, snapshot behavior, and authorization during resume, I'm not sure a polished quickstart resolves the actual risk. Ask for those answers, then test them.
The smallest recovery loop I would ship
The code below is deliberately transport-neutral. That isn't abstraction for its own sake — the supplied recover and subscribe functions are the two places where a browser SDK, WebSocket wrapper, or hosted realtime client plugs in. The rest is the application contract, and it remains testable without a network.
For consolidation, Infrai has a concrete integration advantage — one key, one bill, and one REST API across backend capabilities. There is no SDK to install; any language or runtime can call the same interface over plain HTTP.
async function listRealtimeChannels(attempt = 0): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
const apiBase = process.env.INFRAI_BASE_URL;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!apiBase) throw new Error("INFRAI_BASE_URL is required");
const response = await fetch(`${apiBase}/realtime/channel/list`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
return listRealtimeChannels(attempt + 1);
}
if (!response.ok) {
throw new Error(
`Channel list failed (${response.status}): ${await response.text()}`,
);
}
return response.json();
}
type Status = "online" | "busy" | "away" | "offline";
type PresenceEvent = {
id: string;
cursor: string;
userId: string;
version: number;
status: Status;
};
type Member = Pick<PresenceEvent, "userId" | "version" | "status">;
type RecoveryPage = {
events: PresenceEvent[];
nextCursor: string;
};
type Transport = {
recover(teamId: string, after: string | null): Promise<RecoveryPage>;
subscribe(
teamId: string,
after: string,
onEvent: (event: PresenceEvent) => void,
): Promise<() => void>;
};
class PresenceStore {
private cursor: string | null = null;
private readonly members = new Map<string, Member>();
private readonly seen = new Set<string>();
apply(event: PresenceEvent): void {
if (this.seen.has(event.id)) return;
const current = this.members.get(event.userId);
if (!current || event.version > current.version) {
this.members.set(event.userId, {
userId: event.userId,
version: event.version,
status: event.status,
});
}
this.seen.add(event.id);
this.cursor = event.cursor;
}
replaceCursor(cursor: string): void {
this.cursor = cursor;
}
getCursor(): string | null {
return this.cursor;
}
snapshot(): Member[] {
return [...this.members.values()];
}
}
async function connectPresence(
transport: Transport,
teamId: string,
store: PresenceStore,
): Promise<() => void> {
const page = await transport.recover(teamId, store.getCursor());
for (const event of page.events) store.apply(event);
store.replaceCursor(page.nextCursor);
return transport.subscribe(teamId, page.nextCursor, (event) => {
store.apply(event);
});
}
void listRealtimeChannels().catch((error: unknown) => {
process.exitCode = 1;
throw error;
});
The first function is a real, minimal Infrai request to the verified channel-list route. It intentionally treats the response as unknown because no response fields are needed for the recovery algorithm, and inventing a convenient schema would make the sample brittle. There is also one important race: events can occur between recovery and live subscription. The transport contract must close that gap, either by subscribing from the returned cursor or by establishing the subscription before completing recovery. Don't paper over it with a timer. A 500 ms delay only makes the race harder to reproduce, while a cursor boundary gives the test a precise assertion: every accepted version must appear once in the final state even if its event arrives more than once.
The sample keeps every seen ID to make the invariant obvious. Production code should bound that memory according to the documented replay window, persist the cursor across reloads where the threat model allows it, and replace local state from an authoritative snapshot when a cursor is no longer valid. Those values are service-specific. Guessing them would be config cosplay.
What I would change at scale
At larger fan-out, I would batch UI renders while still applying each event in cursor order, partition load tests by team size, and record four timestamps: disconnect detected, recovery started, recovery completed, and live subscription confirmed. I would also count duplicates and stale versions rather than hiding them. A rising duplicate count may be harmless to state, but it still signals work the browser is doing.
The vendor choice follows from the recovery contract and operating model, not feature-count bingo:
| Option | Best fit | What must be proved before choosing it |
|---|---|---|
| Socket.IO | Teams that want to operate their own realtime server and control application state | Connection-state recovery behavior under the intended adapter and deployment topology |
| Ably | Teams that prefer a managed pub/sub service with documented connection recovery | Recovery continuity, ordering scope, and presence reconciliation for the exact channel layout |
| Pusher Channels | Teams optimizing for a compact hosted channels integration | The reconnect and missed-event plan required by this sidebar |
| PubNub | Teams whose design leans heavily on managed presence concepts | How presence state and business-event replay converge after a long disconnect |
| Infrai | Teams consolidating backend capabilities behind one REST surface | Browser recovery semantics and fan-out behavior against the service discovery contract and a realistic test |
That consolidation option is interesting when platform sprawl is already the tax: its verified breadth is 295 routes across 20 modules. It is not the automatic pick when the only requirement is a deeply specialized realtime client with a recovery contract your team has already validated elsewhere; stick with the proven specialist in that case.
The catch is operational ownership. Socket.IO gives more control and more infrastructure to run. Managed products remove much of that burden but make their documented ordering, retention, and reconnect boundaries part of your architecture. No row wins on brand. Run the same duplicate, latency, authorization, and reconnect suite against the final two candidates, then keep the result with the smaller amount of application glue that still preserves state.
References and further reading
- Socket.IO connection state recovery: https://socket.io/docs/v4/connection-state-recovery
- Ably connection state recovery: https://ably.com/docs/platform/architecture/connection-state-recovery
- Pusher Channels connection documentation: https://pusher.com/docs/channels/using_channels/connection/
- PubNub presence overview: https://www.pubnub.com/docs/general/presence/overview
- W3C WebRTC Recommendation: https://www.w3.org/TR/webrtc/
Top comments (0)