Short answer: use a room lifecycle with explicit token scope, presence state, and recovery rules; pick the realtime API that makes those boundaries visible instead of hiding them in a client SDK.
For an e-commerce delivery tracking map, “online” is not a boolean. A dispatcher can have a valid login but no map subscription. A courier can reconnect with an old token. A browser can receive the same position twice after a mobile handoff. The useful contract is four states: authenticated, subscribed, publishing, and recoverable. Treat each transition as observable data.
What should a delivery map room own?
Start with ownership. The server decides who may enter a workspace and which delivery events they may publish. The client renders presence and sends intent; it never grants itself a wider scope because a map component asked for it.
I keep three streams separate: authentication, subscription state, and business events. That sounds fussy until a courier disappears from the map. You need to know whether the token expired, the subscription was dropped, or the last GPS event was simply delayed. One undifferentiated “socket error” gives you none of that.
The room should carry a workspace identifier, a short-lived token scope, and a monotonically increasing event sequence. Presence entries should include a role such as courier or dispatcher, a last-seen timestamp, and the sequence at which that state changed. A map can then show “stale” without pretending that a courier is offline.
How do token scope and client trust shape the lifecycle?
Issue the narrowest token that can render one workspace. A dispatcher may subscribe to all couriers in that workspace; a courier may publish only its own location. Keep those permissions server-side and re-check them on reconnect. Client-side role flags are presentation hints, not authorization.
The client state machine is deliberately boring. Boring is good.
type RoomState = "authenticated" | "subscribed" | "publishing" | "recoverable";
type Presence = {
userId: string;
role: "courier" | "dispatcher";
lastSeenMs: number;
sequence: number;
};
export function nextState(
state: RoomState,
event: "token_ok" | "subscription_ok" | "publish_ok" | "disconnect" | "token_expired",
): RoomState {
if (event === "disconnect" || event === "token_expired") return "recoverable";
if (event === "token_ok") return "authenticated";
if (event === "subscription_ok" && state === "authenticated") return "subscribed";
if (event === "publish_ok" && state === "subscribed") return "publishing";
return state;
}
export function acceptPresence(previous: Presence | undefined, incoming: Presence): Presence | undefined {
if (previous && incoming.sequence <= previous.sequence) return previous;
return incoming;
}
On disconnect, the server-side cleanup call should be explicit. The documented surface exposes POST /v1/realtime/user/disconnect; use it when a session is intentionally revoked, then let the room broadcast the resulting presence change. For an automatic network loss, mark the client recoverable first and use a bounded reconnect loop. Do not delete a courier immediately just because one heartbeat missed.
Ship it.
What does a minimal recovery loop look like?
Recovery is part of normal operation, not an exceptional branch. Expired credentials need a fresh token. Duplicate delivery needs sequence checks. Partial failure needs a visible state so the UI can avoid claiming current positions.
Here is the retry shape I use around a disconnect notification. It has an explicit method, bearer authentication from the environment, Retry-After support, and an idempotency key so a retry cannot apply the same intent twice.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required"