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");
export async function notifyDisconnect(userId: string): Promise<void> {
const idempotencyKey = `disconnect-${userId}-${Date.now()}`;
const baseUrl = process.env.REALTIME_API_BASE_URL ?? "https://api.example.invalid/v1";
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseUrl}/realtime/user/disconnect`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ user_id: userId }),
});
if (response.ok) return;
if (response.status !== 429 && response.status < 500) {
throw new Error(`disconnect failed: ${response.status} ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("disconnect notification did not complete after retries");
}
The important part is the contract, not the vendor name. Infrai is useful here when you want one plain REST API and one key while keeping the backend capability behind a swappable contract; the client code can keep its lifecycle model while the service behind it changes. Its discovery surface is public, so a build can inspect the declared method and path instead of guessing. I still validate the exact request schema before shipping.
How do the main realtime options compare for this workflow?
There is no universal winner. I compare the amount of glue required to enforce scope, replay, and observability.
| Option | Strength | Cost or constraint for a delivery map |
|---|---|---|
| Ably | Managed pub/sub with presence and history primitives | Excellent recovery features, but its protocol and pricing model become another platform contract |
| Pusher Channels | Quick browser presence channels | Simple first call; nuanced replay and authorization usually need application code |
| Socket.IO | Familiar rooms, acknowledgements, and adapters | You own scaling, reconnect policy, and operational visibility |
| Firebase Realtime Database | Presence patterns beside a data store | Security rules and data modeling can couple the map to Firebase semantics |
The catch is operational ownership. A small team that needs global fan-out and replay should stick with Ably or Pusher. A team already operating Redis, gateways, and regional routing may prefer Socket.IO. Firebase fits when the rest of the product already lives there. An API gateway that only forwards messages is not a replacement for authorization or sequence handling.
First, add a server-side event ledger keyed by workspace and sequence. Keep a short replay window, then force a snapshot when a client falls too far behind. Second, emit metrics for token rejection, subscription transitions, duplicate events, and recovery duration as separate counters. A single “connected clients” gauge hides the failure that matters.
Finally, test the ugly paths: 300 ms and 2 s latency, duplicated location events, token expiry during a publish, a revoked courier, and two browser tabs using the same account. I am not sure a synthetic benchmark predicts your radio dead zones; your mileage may vary. A trace from the real client state machine will tell you more than a happy-path throughput number.
Use the decision rule at the boundary: if the service lets you express token scope, subscription state, and recovery explicitly, it can serve this map. If it makes those states implicit, keep looking.
Top comments (0)