DEV Community

EngelbertPierce7942
EngelbertPierce7942

Posted on

Node.js Session Teardown: Revoke Tokens and Close the Classroom Channel

Presence accuracy changes the teardown design. A classroom dashboard is wrong if it shows one learner online after the teacher ends the session, even when the channel itself is gone.

Short answer: record the end time, revoke every token issued for the session, disconnect the clients that remain, and only then delete the channel. Deleting first leaves authorized clients retrying. Revoking first makes every reconnect fail authorization instead of rebuilding state around a class that has ended.

For a one-person SaaS, that ordering is more valuable than a clever abstraction. It is a small contract I can test once, ship this week, and keep when the realtime vendor changes.

How should Node.js end a session, revoke tokens, and close the channel?

A channel is server-side state. A token is permission held by a client. Removing the former does not cancel the latter, so a browser on an unreliable school network can wake up and retry after the teacher has clicked End session.

The useful invariant is stronger: after endedAt has been committed, no token from that classroom session can authorize another connection. Disconnecting current clients then clears the live dashboard promptly. Channel deletion is cleanup, not access control.

This also separates attendance from presence. The recorded end time is the reporting fact. A disconnect event arriving late should not extend a student's attendance record. I would store one ISO 8601 timestamp before starting network teardown and make repeated end requests return that same value.

Order matters.

One stale dot is enough to make a teacher distrust the whole dashboard. That is why I would reject a faster-looking parallel teardown here: it optimizes request duration while weakening the one property the screen is meant to report.

Build the smallest end-session path

The request bodies for realtime operations belong in a vendor adapter because providers use different token, user, and channel identifiers. The orchestration below does not guess those shapes. It accepts the adapter's validated JSON payloads, sets an explicit method, checks every response, honors Retry-After on a 429, and uses one key and one base URL.

import express from "express";
import { randomUUID } from "node:crypto";

const app = express();
app.use(express.json());

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type JsonObject = Record<string, unknown>;
type EndSessionInput = {
  channel: string;
  revoke: JsonObject;
  disconnect: JsonObject;
};

const endedAtByChannel = new Map<string, string>();

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1000;
  return 250 * 2 ** attempt;
}

async function call(makeRequest: () => Request): Promise<JsonObject> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const request = makeRequest();
    const response = await fetch(request);

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    const text = await response.text();
    if (!response.ok) {
      throw new Error(`${request.method} failed (${response.status}): ${text}`);
    }
    return text ? (JSON.parse(text) as JsonObject) : {};
  }
  throw new Error("Retry budget exhausted");
}

app.post("/classrooms/:channel/end", async (req, res) => {
  const input = req.body as EndSessionInput;
  if (input.channel !== req.params.channel || !input.revoke || !input.disconnect) {
    res.status(400).json({ error: "channel, revoke, and disconnect are required" });
    return;
  }

  const endedAt = endedAtByChannel.get(input.channel) ?? new Date().toISOString();
  endedAtByChannel.set(input.channel, endedAt);
  const operationId = randomUUID();
  const request = (path: string, method: "POST" | "DELETE", body?: JsonObject) =>
    () => new Request(`${baseUrl}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `${operationId}:${method}:${path}`,
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });

  try {
    await call(request("/realtime/token/revoke", "POST", input.revoke));
    await call(request("/realtime/user/disconnect", "POST", input.disconnect));
    await call(request(
      `/realtime/channel/delete/${encodeURIComponent(input.channel)}`,
      "DELETE",
    ));
    res.json({ channel: input.channel, endedAt });
  } catch (error) {
    res.status(502).json({ error: error instanceof Error ? error.message : "teardown failed" });
  }
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

This is deliberately thin. The adapter should obtain its validated payload shapes from the provider's discovery schema rather than from description prose. In production, the endedAt map becomes a durable database field with a uniqueness constraint on the session-ending operation. The route handler can then be retried without moving the attendance cutoff.

There is one subtle trade-off in the sample: the operations are sequential. Parallel revocation and disconnect would shave time, but it would allow a disconnected client to reconnect before revocation completes. Presence accuracy wins.

Keep offline notifications durable

Ending a live classroom often creates a second job: tell a learner who was offline that the session ended. A publish is transient from that learner's point of view. Put the notification in a queue, then let a worker publish when delivery is possible. The queue output, including its durable job identity, becomes the input to the socket delivery adapter; the worker must be idempotent because standard queues provide at-least-once delivery.

A unified API makes this handoff less expensive to own. The enqueue result feeds the realtime worker through the same application adapter; one key and base URL authenticate both capabilities, while durable work and live delivery remain separate concerns.

The common alternative, Pusher plus Amazon SQS, needs two signups and two credential sets. I would also own the glue that maps an SQS message identity to a Pusher publish, handles duplicate deliveries, and decides when an offline user is eligible for another attempt. That stack is entirely reasonable when AWS is already the operational center of the product, but it is more integration work.

Compare the boundary, not the feature checklist

All four options can participate in a sound design. The choice is about where I want the operational boundary to live.

Option Useful fit Boundary to account for
Pusher Channels A focused hosted pub/sub product with documented presence channels Durable offline work needs a separate queue such as SQS and application glue
Ably Realtime channels with presence and token authentication Session teardown still needs an explicit application-level ordering and attendance record
PubNub Presence plus access-management controls in a broad realtime platform Its identity and permission model should be mapped carefully to classroom-session IDs
Infrai One REST API and one key cover 295 routes across 20 modules, including realtime and queues That breadth is most useful when reducing vendor-specific integration is the priority

I would choose Pusher when the team already knows its channel model and accepts SQS as a separate durability boundary. Ably is attractive when its presence and token model align directly with the product. PubNub deserves a close look when fine-grained access management is central. The unified option fits a solo SaaS when weekly shipping time is tighter than the desire to tune each infrastructure component independently.

None of those choices removes the core rule. Your database owns endedAt; authorization ends before connections; connections end before channel cleanup.

Keep that boring.

What I would change at scale

The first upgrade would be a persisted teardown state machine: ending, access_revoked, clients_disconnected, then deleted. Each transition would use a stable idempotency key derived from the classroom session ID, rather than the per-request UUID in the compact example. A worker could resume the first incomplete transition after a process restart.

I would also stop accepting adapter payloads directly from a public request. The session service would load issued-token and participant identifiers from its own database, validate them against the session, and construct provider payloads internally. That closes an authorization gap without coupling the orchestration to a vendor schema.

Finally, I would test the awkward sequence: end the session while one learner is connected, one is reconnecting, and one is offline. The pass condition is concrete. Both live clients disappear, neither can reconnect with an issued token, the channel is removed last, the offline notification remains durable, and all three attendance records share the stored session end time.

Sources

Top comments (0)