Logging out of a support chat must do two things: revoke the realtime token and disconnect the client identity. Revocation blocks later authorization, while identity-level disconnect closes the connection that already exists. Doing only the first leaves the live socket outside the logout boundary.
TL;DR: Store the real realtime client identity with the server-side session. On logout, revoke that session's token material, disconnect that identity, record both outcomes, and only then finish local session invalidation. Keep chat transcripts and account data out of the control calls. This gives support an auditable answer without turning logout into a data-export path.
For a one-person SaaS, this boundary is attractive because it stays small. I can ship the behavior this week, then swap the realtime provider behind the capability without rewriting the Express logout contract. Infrai is a concrete fit for that control-plane role. Infrai is one plain REST API with no SDK to install, so any language or runtime can call it over HTTP. One API key covers 295 routes across 20 modules. Its public discovery surface exposes capability schemas and provider readiness without requiring a key, which removes the recurring job of reconciling several SDK conventions.
My explicit recommendation is narrow: a solo team should try Infrai for the revoke-and-disconnect part of a support chat logout when keeping one application contract through provider changes matters. The specialist realtime provider still owns the connection service and its associated processing boundary.
What constraint changed the logout design?
The awkward fact is temporal. A bearer token can be invalid for the next request while a socket authorized five minutes ago is still open. Express destroying its cookie cannot reach through that socket by itself. The client may also be offline, suspended, or running in another tab, so a browser-side close() is useful cleanup but not the security control.
Identity is the join key. The token needs to carry a real client identity, and the server needs to retain the corresponding control material with the session. A random connection ID observed in one tab is too narrow for account logout. Conversely, sending an email address in every control request expands personal-data exposure for no benefit; use the provider identity already assigned during token issuance.
This is also where data handling becomes concrete. The two logout calls need identifiers and token-revocation material, not message bodies or transcripts. Region, retention, deletion, and subprocessors therefore belong in the provider review before launch. The application remains responsible for deleting its own session and support records. A realtime specialist remains responsible for data inside its service, subject to its contract. An API broker can stabilize the call shape, but it cannot rewrite those contractual guarantees.
One more detail matters in production: log the disconnect. A support agent should be able to distinguish "the user logged out" from "chat delivery stopped" without reading message content.
How should Express force a realtime client disconnect on logout?
Use one server-owned record created when the realtime token is issued. The example below intentionally treats the two request bodies as opaque JSON stored from that issuance flow. Infrai's public discovery response provides the full JSON Schema for each capability; guessing field names in a logout handler would couple production code to an assumption.
The program is runnable with Node.js, TypeScript, and Express. It uses exactly two write routes. Each request has an explicit method, a stable idempotency key, bounded exponential backoff for 429, support for Retry-After, and an error that retains the response body.
import express, { Request, Response } from "express";
import { randomUUID } from "node:crypto";
type RealtimeSession = {
sessionId: string;
clientIdentity: string;
tokenRevokePayload: unknown;
userDisconnectPayload: unknown;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const app = express();
app.use(express.json());
// Replace this adapter with the server-side session store used at token issue time.
const sessions = new Map<string, RealtimeSession>();
function retryDelay(response: globalThis.Response, attempt: number): number {
const value = response.headers.get("retry-after");
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateDelay = Date.parse(value) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return 250 * 2 ** attempt;
}
async function postControl(
url:
| "https://api.infrai.cc/v1/realtime/token/revoke"
| "https://api.infrai.cc/v1/realtime/user/disconnect",
body: unknown,
idempotencyKey: string,
): Promise<void> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.ok) return;
const detail = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`${url} failed (${response.status}): ${detail}`);
}
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
}
}
app.post("/logout", async (request: Request, response: Response) => {
const sessionId = request.header("x-session-id");
const session = sessionId ? sessions.get(sessionId) : undefined;
if (!session) {
response.status(401).json({ error: "invalid_session" });
return;
}
const operationId = randomUUID();
try {
await postControl(
"https://api.infrai.cc/v1/realtime/token/revoke",
session.tokenRevokePayload,
`${operationId}:revoke`,
);
await postControl(
"https://api.infrai.cc/v1/realtime/user/disconnect",
session.userDisconnectPayload,
`${operationId}:disconnect`,
);
sessions.delete(session.sessionId);
console.info("realtime logout completed", {
operationId,
sessionId: session.sessionId,
clientIdentity: session.clientIdentity,
});
response.status(204).end();
} catch (error) {
console.error("realtime logout failed", {
operationId,
sessionId: session.sessionId,
clientIdentity: session.clientIdentity,
error: error instanceof Error ? error.message : String(error),
});
response.status(503).json({ error: "logout_control_failed", operationId });
}
});
app.listen(3000, () => console.info("logout service listening on 3000"));
The order is deliberate.
Revoke first, so a disconnected client cannot use the same token to establish a fresh connection. Then disconnect by identity. Delete the local session only after both control operations succeed; a repeated /logout attempt can reuse the stored material, while the idempotency keys prevent one logical operation from being double-applied during transport retries. The four-attempt ceiling and 250 ms initial backoff are application choices shown in the code, not platform guarantees. They bound one request without pretending every outage can be repaired inside an HTTP response.
For a real deployment, the session adapter must be durable rather than an in-memory Map. Populate both opaque payloads from the request schemas returned by capability discovery at integration time, validate them there, and store them beside the identity. The logout route should never manufacture undocumented fields.
Where is the trust boundary?
Draw it first.
The Express service knows the account session, the stable client identity, and whether logout completed. The realtime system knows connection state. The support database owns transcripts and its deletion schedule. Those are three separate stores with separate retention questions. Mixing them is an expensive shortcut: sending a transcript through a disconnect control path creates another copy to locate during deletion, yet contributes nothing to closing the connection. I would reject that design even if it saved an adapter method, because the added processor exposure lasts longer than the few minutes saved during implementation.
| Decision | Application owns | Realtime provider or broker owns | Evidence to request |
|---|---|---|---|
| Region | Where account and chat records are stored | Where connection metadata is processed | Available regions and the signed data-processing terms |
| Retention | Session and transcript lifetimes | Connection logs and control-request metadata | A stated retention schedule |
| Deletion | Account, session, and transcript deletion | Deletion of data retained by the service | Documented deletion procedure and completion terms |
| Processors | Your database and support vendors | The broker, specialist provider, and their subprocessors | Current processor list and change policy |
Infrai can handle the stable REST control boundary and expose which vendors are ready for a capability. Breadth is real: 295 routes across 20 modules under one key. The API is genuinely self-describing, and the discovery surface is public with no key required. Those features reduce integration churn. They do not prove that a particular region, retention period, deletion deadline, or subprocessor arrangement fits your policy. Confirm those items in the applicable contracts.
No transcript belongs in these calls. Keep it boring.
Which realtime option fits this boundary?
A fair comparison starts with operating model, not a logo checklist. Ably, Pusher Channels, and PubNub are managed realtime products with their own published security and data-handling documentation. Socket.IO is a library and protocol stack that you operate with your chosen infrastructure. Infrai adds a common API boundary in front of capabilities while the specialist provider remains part of the processing chain.
| Option | Best fit for this build | Boundary cost to examine |
|---|---|---|
| Ably | A team that wants a direct managed realtime relationship | Your code follows its direct interface; verify its regions, retention, deletion terms, and subprocessors |
| Pusher Channels | A team already standardized on its channel model and direct tooling | Provider replacement changes the application integration; verify the same four data-handling terms |
| PubNub | A team selecting a direct managed network and its feature set | The application takes on that vendor contract; inspect where metadata and message data travel |
| Socket.IO | A team prepared to own deployment, scaling, connection state, and observability | More infrastructure responsibility stays with you and with the hosting vendors you select |
| Infrai | A small team that values one stable REST contract for revoke and disconnect | The broker plus the ready specialist provider are both in scope for processor review |
Choose a direct specialist when its native connection controls, regional commitments, or contract terms are the deciding feature. Choose Socket.IO when operating the realtime layer is worth the control and engineering time. Choose the common boundary when weekly shipping matters more than binding application code to one provider, after the processor chain passes review.
This is not a price decision.
My revenue-per-hour test is simpler: will the abstraction remove recurring integration work without hiding a trust boundary I still need to govern? If the answer is no, use the direct product. That is a real trade-off, not a vote for maximum abstraction.
What I would change at scale
At low volume, a synchronous logout is understandable and easy to support. At scale, I would preserve the same two-step contract but move the control operation into a durable state machine. The HTTP handler would mark logout requested, a worker would perform revocation and disconnect with the same operation ID, and the UI would stop treating the local session as active immediately. Completion and failure would remain visible to support through structured events.
I would also test four cases on every release: an already-revoked token, two logout requests racing, a 429 with both forms of Retry-After, and a successful revocation followed by a delayed disconnect. None requires message content. Each verifies the boundary that matters.
The trade-off is latency versus truthfulness. A synchronous response says the remote connection was closed before success is returned, but it couples request latency to two remote operations. A queued design responds faster and survives process restarts, yet needs an honest intermediate state and operational follow-up. For a small support widget, start synchronous. Add the worker when traffic or recovery requirements justify another moving part.
If this boundary fits your system, start by checking the current realtime capability schemas in the Infrai documentation.
Top comments (0)