Short answer: model presence expiration, reconnect, and backfill as one server-owned state machine, then permit a live customer support poll to resume only after a fresh authorization decision and a conversation-scoped cursor check.
The deciding constraint isn't WebSocket uptime. It is whether the system can prove that an agent who disappeared and returned may still receive this conversation's events. A socket can reconnect while its old authorization is no longer valid. Conversely, a brief network gap needn't erase a legitimate vote if the event log can replay it safely.
The useful before/after picture is small. Before: connection status paints a green dot, and reconnection means "continue." After: an expiring lease says what the server currently knows, a reconnect creates a new authorization decision, and a cursor says exactly where delivery may resume. Three signals. Three jobs.
Start with a reconnect acceptance trace
Write the expected trace before choosing a transport or a timeout. For a live poll in a support session, use two browser tabs for the same agent. Tab A receives poll event 41; Tab B sleeps long enough for its presence lease to expire. While B sleeps, the poll receives events 42 and 43, and the agent's conversation membership is re-evaluated. B then returns carrying cursor 41.
The server must not translate "the network is back" into "access is back." It first authenticates the returning session, checks current conversation membership, issues a new lease if that decision succeeds, and only then evaluates the backfill request. If membership no longer applies, no poll events are replayed. If it does apply, replay begins after 41, stays inside that conversation, and delivers 42 and 43 in cursor order. A retry with cursor 41 may deliver the same event envelope again, so applying a vote needs a stable voteId and idempotent handling.
That trace is more useful than a generic "reconnect succeeded" test because every line has a security meaning. It also forces an awkward but important question: what does the UI display between lease expiry and renewed authorization? Use unknown or reconnecting, not online. Silence is not evidence.
Here is the acceptance sequence in compact form:
| Observed input | Server decision | Visible state |
|---|---|---|
| Valid refresh before expiry | Extend the server-owned lease | Online |
| No refresh by expiry | End the lease | Unknown |
| Reconnect with current membership | Issue a new lease | Reconnecting, then online |
| Backfill after authorization | Replay allowed events after the cursor | Poll catches up |
| Reconnect without membership | Create no lease and replay nothing | Access ended |
This table is the test oracle. Keep it close to the code.
What should realtime customer support chat log when presence expiration fires?
Log decisions and transitions, not chat text or poll answers. A useful transition record contains a correlation ID, pseudonymous user and conversation identifiers, the session identifier, the previous and next state, the cursor, and a bounded reason code such as lease_elapsed, access_confirmed, or membership_denied. That is enough to reconstruct control flow without copying customer content into the observability system.
Treat the lifecycle as a finite set of states: active, expired, authorizing, backfilling, and closed. A refresh can move active to active. Time moves it to expired. A reconnect moves expired to authorizing; only a successful current authorization can move onward to backfilling, and only completed replay can restore active. There is deliberately no direct edge from expired to active.
This is where dashboards often lie. A chart of open connections can look healthy while agents are receiving stale or unauthorized state. Track the age of the last accepted refresh, reconnect authorization outcomes, cursor lag at backfill start, replayed event count, duplicate vote acknowledgements, and time spent in unknown. Then alert on impossible transitions and sustained changes in those rates, rather than treating raw socket count as the security signal.
Short version: observe the proof.
Make invalid transitions impossible in code
The copyable core below is transport-neutral TypeScript. It does not assume a vendor endpoint, and the 60_000 millisecond lease is an example policy value rather than a protocol default. The caller supplies a trusted server clock and the current authorization result.
type PresenceState =
| { kind: "active"; sessionId: string; expiresAt: number; cursor: number }
| { kind: "expired"; lastCursor: number }
| { kind: "authorizing"; lastCursor: number }
| { kind: "backfilling"; sessionId: string; after: number; expiresAt: number }
| { kind: "closed"; reason: "membership_denied" | "signed_out" };
type PollEvent = {
conversationId: string;
cursor: number;
voteId?: string;
};
function expire(state: PresenceState, now: number): PresenceState {
if (state.kind !== "active" || state.expiresAt > now) return state;
return { kind: "expired", lastCursor: state.cursor };
}
function beginReconnect(state: PresenceState): PresenceState {
if (state.kind !== "expired") throw new Error("invalid_transition");
return { kind: "authorizing", lastCursor: state.lastCursor };
}
function authorizeReconnect(
state: PresenceState,
allowed: boolean,
sessionId: string,
now: number,
): PresenceState {
if (state.kind !== "authorizing") throw new Error("invalid_transition");
if (!allowed) return { kind: "closed", reason: "membership_denied" };
return {
kind: "backfilling",
sessionId,
after: state.lastCursor,
expiresAt: now + 60_000,
};
}
function selectBackfill(
state: PresenceState,
conversationId: string,
events: PollEvent[],
): PollEvent[] {
if (state.kind !== "backfilling") throw new Error("invalid_transition");
return events.filter(
(event) =>
event.conversationId === conversationId && event.cursor > state.after,
);
}
Notice what the types refuse to express. An expired state has no valid expiresAt; an authorizing state cannot select replay events; and a denied session has no cursor with which to continue. The model doesn't make authorization correct by itself, but it gives tests and logs named transitions to inspect.
Keep presence and poll events separate. Presence answers "may this session currently participate?" The event cursor answers "which authorized delivery comes next?" Combining them into one mutable row makes reconnect behavior hard to reason about: a refresh can accidentally advance delivery, or a replay can accidentally revive presence. Separate records make the before/after trace crisp.
Test time, identity, and replay together
A fake clock is the highest-leverage test tool here. Advance it to one millisecond before expiry and confirm a valid refresh remains possible; advance through expiry and confirm the same state cannot jump straight back to active. Then reconnect with current membership, backfill from an older cursor, submit the same voteId twice, and assert one logical vote. Repeat with membership denied and assert zero replayed events.
Don't stop at the happy path. Run the trace with three tabs, token rotation between expiry and reconnect, an old cursor, reordered event input, and a replay request aimed at a different conversation. These are deterministic cases, so failures should identify a transition and reason code rather than leave someone comparing packet captures. A transport-level health check belongs in the suite, but it cannot replace the authorization assertions.
The timer needs production evidence. I'm not sure a universal presence lease duration exists: shorter leases narrow the stale-presence window but increase refresh traffic and visible transitions through unknown, while longer leases tolerate sleep and packet loss at the cost of slower expiry. Measure reconnect latency, client sleep behavior, and the acceptable stale-access window for the support workflow. Then set both the lease and alert thresholds from those observations.
WebRTC is one possible transport for realtime data, and its recommendation defines peer connection behavior. The application still owns its session authorization, lease policy, event scope, and audit record. The same state machine can sit above WebSocket or Server-Sent Events because none of those transports decides who may read a support conversation.
Two objections worth resolving before deployment
"Can a heartbeat do this?" It can provide evidence that a client was recently reachable, and that may be enough for a low-risk availability indicator. It is not suitable as the only control when a live poll affects a customer outcome or reveals conversation data. In that case, keep the heartbeat as lease-refresh input, but require current authorization before reconnect and backfill.
"Should every reconnect force a full poll reload?" Usually no. A conversation-scoped cursor permits bounded replay and makes retries explicit, while a full reload can increase payload size and blur which events were missed. The catch is that cursor retention, redaction, and idempotency become operational responsibilities. Stick with a full authorized snapshot when the dataset is tiny and history has no independent value; use ordered backfill when explaining event delivery matters.
There is another boundary. Presence expiration is not proof of high-assurance identity, payment approval, or legal consent. Those flows need their own authorization and explicit confirmation. A lease can stop stale delivery; it cannot carry meaning it was never designed to represent.
For the support poll, the deployment decision is therefore concrete: ship only when the acceptance trace is visible in tests and telemetry, the expired-to-active shortcut is impossible, and backfill is both conversation-scoped and idempotent. The green dot comes last.
Top comments (0)