Short answer: for realtime presence expiration, use a server-owned, short-lived lease, expire it on a monotonic deadline, and make reconnect plus backfill prove identity before exposing any status. Those are the security controls I would put in a customer support chat; they stop a stale "agent online" badge from becoming an authorization signal.
I build tools for developers, so I start with the smallest state machine I can test. For an edtech support chat, that means one lease per agent and device, an event stream for changes, and a bounded replay window for clients that were offline. Polling is not part of the design. The browser listens; the server decides.
How should realtime presence expiration protect a customer support chat?
Presence is a hint about availability, not proof that a person may read a ticket. Keep those concerns separate. A request to fetch a conversation still checks the agent's session, role, tenant, and ticket ACL even when presence says online.
Represent a lease with an opaque subject, a connection identifier, an issued-at timestamp, and an expiry timestamp. Store the expiry in server time. Client clocks are input, not authority. A 30-second lease with a 10-second renewal cadence gives a dead tab a bounded lifetime while leaving room for a brief network pause; tune those values from your measured reconnect distribution, not from a copied blog post.
The expiry path must be idempotent. If a renewal and an expiry race, compare the lease version (or a fencing token) before publishing offline. An old timer must not cancel a newer lease. That single check prevents the most embarrassing class of presence bugs: an agent appears offline seconds after sending a heartbeat.
WebRTC's security model is a useful reminder here: signaling and authorization are application responsibilities, while the transport protects the channel. The same split applies to WebSocket presence. TLS protects bytes in transit; it does not decide which tenant may see an agent's state.
One sentence is enough.
A lease state machine that survives reconnects
Use explicit transitions: absent -> pending -> online -> expired, with online -> pending on a transport loss. Do not jump directly from a TCP close to a globally visible offline event. A mobile client can lose Wi-Fi, reconnect through a new socket, and present a valid lease before the old close callback runs.
Here is a compact TypeScript model. It is deliberately boring: a map, a version, and injected time make the edge cases easy to exercise.
type PresenceState = "absent" | "pending" | "online" | "expired";
type Lease = {
agentId: string;
tenantId: string;
connectionId: string;
version: number;
state: PresenceState;
expiresAtMs: number;
};
const leases = new Map<string, Lease>();
export function renewLease(
key: string,
input: Omit<Lease, "version" | "state">,
nowMs: number,
ttlMs = 30_000,
): Lease {
const previous = leases.get(key);
const next: Lease = {
...input,
version: (previous?.version ?? 0) + 1,
state: "online",
expiresAtMs: nowMs + ttlMs,
};
leases.set(key, next);
return next;
}
export function expireLease(
key: string,
expectedVersion: number,
nowMs: number,
): Lease | undefined {
const current = leases.get(key);
if (!current || current.version !== expectedVersion || current.expiresAtMs > nowMs) {
return current;
}
const expired = { ...current, state: "expired" as const };
leases.set(key, expired);
return expired;
}
The caller schedules expiry with a monotonic timer, then emits a change only when expireLease returns expired. In a distributed deployment, put this compare-and-set in the shared store, not in each process's memory. The event should carry tenantId, agentId, state, version, and occurredAt; it should not carry ticket text or access tokens.
Reconnect and backfill are the security boundary
Reconnect is where an apparently harmless status feature turns into a data leak. Authenticate the new connection, bind it to a tenant and role, and issue a fresh connection ID. Never let a client choose a cursor belonging to another tenant.
For each tenant, keep a short event log with monotonically increasing sequence numbers. On reconnect, the client sends its last acknowledged sequence. The server validates the session first, then returns events after that sequence that the session is allowed to see. If the cursor is older than the retention window, return a snapshot instead of guessing. A snapshot is generated under the same authorization check as a live event.
The replay response needs a clear outcome: replayed, snapshot_required, or forbidden. I use 4401 for an unauthenticated WebSocket close in one internal protocol, but your protocol may choose another code; document it and test it. Do not treat a close code as a substitute for an HTTP authorization decision.
Backfill also needs a limit. Cap events per response and require another acknowledged cursor for the next page. This prevents a reconnecting tablet from forcing an unbounded read and makes rate limiting visible in metrics. Log the request ID, tenant, subject, cursor, result, and count. Avoid logging message bodies.
I'm not sure a single retention period fits every support queue. Legal deletion rules and the longest realistic outage decide that number. Measure those inputs, then set retention with a written reason.
Compare transports by failure behavior, not demos
The transport is a replaceable adapter. WebSocket is a practical default for bidirectional chat events. Server-Sent Events can simplify one-way server updates, but the client still needs a separate request path for heartbeats and acknowledgements. WebRTC data channels can provide peer-to-peer paths, yet they still need signaling, identity checks, and a server-owned presence record. A self-hosted broker gives control over placement and retention, at the cost of operating capacity and upgrades.
| Option | Useful when | Verify before choosing |
|---|---|---|
| WebSocket | Chat needs client acks and server pushes on one connection | Auth on handshake, bounded replay, close and reconnect tests |
| Server-Sent Events | Updates are mostly server to browser | Heartbeat path, proxy buffering, replay cursor handling |
| WebRTC data channel | A media or peer path already exists | Signaling authorization and server fallback for presence |
| Self-hosted broker | Data residency or custom retention dominates | Fencing, failover, patching, and tenant isolation |
The catch is operational ownership. A managed transport may remove patching work, while a self-hosted broker may offer controls your compliance team requires. Neither choice makes stale presence safe by itself. Stick with the simpler adapter when your tests cannot demonstrate reconnect, expiry, and forbidden backfill behavior.
Ship gates for an edtech support rollout
Write these tests before wiring a UI badge:
- A lease expires after the server deadline even if the browser stops sending heartbeats.
- A renewal with a newer version wins a race against an old expiry timer.
- A reconnect with a valid session replays only events for its tenant and role.
- An expired cursor returns
snapshot_required, never an unbounded dump. - A revoked session gets
forbiddenand no presence payload. - Duplicate acknowledgements and duplicate expiry jobs leave one final state.
Run the suite with fake time, then run a small live test that drops connections at 5, 15, and 30 seconds. Track expiry delay, reconnect success, replay count, snapshot rate, and authorization denials. A green dashboard is not evidence if it omits denied backfills.
Keep the UI honest: show "last seen" with a timestamp when the state is expired, and never enable a privileged action solely because a dot is green. That tiny copy decision prevents support staff from treating presence as a lock.
Top comments (0)