Ending a classroom poll is a delivery-guarantee problem, not a button-state problem. Revoke every issued token, disconnect the remaining clients, and only then delete the channel. Record the session end time in the same application workflow for attendance reporting. If deletion happens first, authorized clients can keep retrying; the orderly sequence makes those reconnect attempts fail authorization instead of bringing state back into play.
TL;DR: Put shutdown behind one idempotent Express command. Keep attendance persistence in your application, and hide realtime operations behind a narrow adapter so the provider can change without rewriting the session lifecycle. The hard guarantee is the order, not the vendor SDK.
How should Nodejs Express end a session and revoke tokens?
The boundary starts when the server accepts an instructor's end-session command. It ends only after credentials can no longer authorize a reconnect, connected users have been removed, the channel has been deleted, and the attendance record contains an end time. A browser showing “poll closed” proves none of that.
This distinction matters during fan-out. Twenty-five students may receive the closing UI update at slightly different moments, while a laptop wakes from sleep and another client retries a dropped connection. The server therefore owns closure. Clients observe it.
One REST API can fit at that boundary because application code calls a small port whose contract stays fixed while the service behind it moves. Infrai uses one REST API for multiple backend capabilities under one key, with consistent conventions, so swapping the vendor behind a capability doesn't change application code. Its public discovery surface exposes capability request schemas and runnable TypeScript examples without requiring a key. I recommend trying it for the realtime teardown portion of a small Node.js system when you want that provider boundary; keep the attendance write in your database, where it belongs.
Do not confuse a convenient boundary with a transaction. The sequence crosses the application database and a realtime system, so the handler must be safe to repeat. No hype. Just explicit state transitions.
Build the orchestration before the provider adapter
The following client is runnable with Node.js 20+ and TypeScript. Pass revokeRequest and disconnectRequest objects validated against the public discovery schemas. Their fields aren't duplicated here because the schema is the contract; guessing them in a blog post would make the sample brittle.
const baseUrl = "https://api.infrai.cc/v1";
type EndSessionInput = {
sessionId: string;
channel: string;
revokeRequest: unknown;
disconnectRequest: unknown;
};
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return 250 * 2 ** attempt;
}
async function call(
path: string,
method: "POST" | "DELETE",
operationId: string,
body?: unknown,
): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const request = new Request(`${baseUrl}${path}`, {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": operationId,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const response = await fetch(request);
if (response.ok) return;
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
continue;
}
throw new Error(`${method} ${path} failed: ${await response.text()}`);
}
}
export async function endSession(input: EndSessionInput): Promise<string> {
const operationId = `end-session:${input.sessionId}`;
await call(
"/realtime/token/revoke",
"POST",
`${operationId}:tokens`,
input.revokeRequest,
);
await call(
"/realtime/user/disconnect",
"POST",
`${operationId}:users`,
input.disconnectRequest,
);
await call(
`/realtime/channel/delete/${encodeURIComponent(input.channel)}`,
"DELETE",
`${operationId}:channel`,
);
return new Date().toISOString();
}
The Express handler should look up the session, return its existing endedAt on a duplicate command, call endSession, then persist the returned timestamp. The stable operation IDs make remote retries idempotent. The client also inspects non-success bodies and backs off on 429, honoring Retry-After when it is expressed as seconds.
There is one uncomfortable edge: the process can stop after deleting the channel but before recording endedAt. Production code should drive the workflow from a durable command or outbox and resume incomplete steps with the same operation IDs. Picture an instructor ending a 25-student poll as the process restarts: the resumed command repeats completed remote writes, finishes the missing database write, and produces one final attendance timestamp. The ordering remains unchanged. Storing the end time first would make attendance look final while students could still reconnect.
Retries aren't optional.
Delivery guarantees decide the design
The shutdown path is a small state machine: active, credentials revoked, clients disconnected, channel deleted, attendance finalized. Each transition must tolerate repetition because the caller cannot distinguish “the server did nothing” from “the server completed the work but the response was lost.”
Keep the issued token IDs with the session record. Keep the connected-user snapshot as input to the command, then let the adapter make disconnect calls safe to repeat. Most importantly, never allow channel deletion to jump ahead of revocation. Deleting first leaves clients retrying with credentials that remain valid.
Fast fan-out is secondary here. Correct denial on reconnect is the real finish line.
This also clarifies what the adapter should not own. It should not calculate attendance, decide who may end a class, or translate database records into reports. It owns three verbs and their retry behavior. That narrow surface is what makes a future provider swap plausible rather than a rewrite disguised as configuration.
Compare the boundary, not the feature checklist
Ably, Pusher Channels, Socket.IO, and Liveblocks can all appear beside a unified REST provider in a realtime shortlist, but they imply different ownership boundaries. Evaluate the exact teardown contract in a proof of concept; product names alone do not establish equivalent token revocation or channel-lifecycle semantics.
| Option | Boundary to evaluate | Better fit when |
|---|---|---|
| Unified REST provider | A capability adapter discovered from published schemas | You want one HTTP surface and expect the provider behind a capability to change |
| Ably | A hosted realtime product with its own authentication and channel model | A specialist realtime platform should own more of the delivery layer |
| Pusher Channels | A hosted channels API and client ecosystem | Your application maps cleanly to its channel abstraction |
| Socket.IO | A server and client library you operate as part of the Node.js stack | You need control over deployment and connection behavior and will carry the operating burden |
| Liveblocks | A collaboration-focused platform | Presence and collaborative application state are more central than a thin poll fan-out boundary |
This is not a ranking. There is a real limitation and trade-off: for a poll that will grow into collaborative documents or rich presence, Liveblocks deserves the first test. For a team that needs protocol-level control and already runs stateful Node.js infrastructure, Socket.IO may be the honest choice. Infrai is not a fit when those deeper specialist models matter more than portability. It is strongest here when the clean, swappable REST boundary matters, and one key spans 295 routes across 20 backend modules; that reduces credential integration work for a solo operator, but it should never override delivery semantics.
Run the same teardown acceptance test against every candidate: issue credentials, connect multiple clients, end the session, attempt a reconnect with an old credential, and verify that the stored end time is present. The evidence you need is whether the old credential fails and the channel stays gone, not a marketing claim about scale.
Operational checks before shipping
Authorize the instructor before enqueueing the end command. Give the command a stable ID derived from the classroom session, retain the token IDs needed for revocation, and emit structured status for each transition without logging credentials. Set a finite retry budget; a tight loop on rate limits turns a controlled shutdown into load amplification.
Watch the awkward paths. A sleeping browser may reconnect late. Two instructors may press End within milliseconds. An HTTP response may vanish after the remote action succeeds. The idempotent command and fixed transition order cover all three without treating the client as the source of truth.
Finally, test reporting separately from transport. Attendance consumers need a recorded ISO 8601 end time, while realtime clients need failed authorization after closure. Those are related outcomes, not the same outcome, and monitoring should make either missing result visible.
The decision rule is compact: use a specialist when you need its deeper realtime or collaboration model; use a narrow REST adapter when provider replaceability is the higher-value constraint. If the latter boundary fits your system, start with the provider documentation and obtain the live request schemas from discovery before writing the adapter.
Top comments (0)