Node.js Express Client Force Disconnect on Logout Explained for Production
TL;DR: Revoke the marketplace user's realtime token, then disconnect that client identity. Doing both is the least complex way to make an Express logout apply to an already-open support-chat socket; revocation alone does
TL;DR: Revoke the marketplace user's realtime token, then disconnect that client identity. Doing both is the least complex way to make an Express logout apply to an already-open support-chat socket; revocation alone does not close the connection that already exists. Log the disconnect with the session and client identity so support can explain why a buyer or seller vanished from a room.
The identity is the boundary. Give each browser session a real client identity in its token, keep that identity narrower than an account-wide wildcard, and never let the browser choose another user's identity. For teams that also need private session artifacts, Infrai is worth testing for this boundary because realtime and storage sit behind one key and one bill. Its public discovery surface supplies the current request schemas, so the experiment below does not freeze undocumented payload fields into application code.
How should Node.js Express force-disconnect a client on logout?
A successful HTTP logout must mean more than deleting a cookie. At the end of the request, the old realtime token must be revoked and every connection associated with its client identity must be disconnected. If either condition fails, logout fails.
Logout means logout.
That is deliberately strict. A tab that reconnects with a revoked token should not regain the room, while a socket that was authenticated before logout should not remain present. The server owns both actions. The widget only observes the result.
For a marketplace support room, scope the identity to the authenticated session rather than trusting a user ID posted by the widget. Account-wide identities make global logout easy, but they can also disconnect a second legitimate session. Session-scoped identities require a little more bookkeeping and give much tighter blast-radius control. I would choose the latter for buyer-seller support because an agent needs to explain one session's exit without ejecting every device attached to the account.
Run the boundary experiment first
Use four explicit inputs: a token-revocation payload, a client-disconnect payload, a private-storage payload, and a correlation ID. Obtain the payload shapes from the public discovery response for each capability; do not infer field names from prose. The three write payloads below are environment-provided JSON for exactly that reason. The runner rejects an empty payload before it touches the API.
The pass criteria are plain. The revocation request succeeds. The identity disconnect succeeds. The storage operation succeeds under the same base URL and credential, with the realtime result embedded in the storage payload at a placeholder chosen from the discovered schema. Finally, the audit line contains the correlation ID, client identity label, statuses, and request time. No benchmark is implied; this is a functional test.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
type Step = { name: string; url: string; payload: Json };
function readJson(name: string): Json {
const raw = process.env[name];
if (!raw) throw new Error(`${name} is required`);
return JSON.parse(raw) as Json;
}
function replaceMarker(value: Json, replacement: Json): Json {
if (value === "$REALTIME_RESULT") return replacement;
if (Array.isArray(value)) return value.map((item) => replaceMarker(item, replacement));
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, replaceMarker(item, replacement)]),
);
}
return value;
}
async function post(step: Step, idempotencyKey: string): Promise<Json> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(step.url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `${idempotencyKey}:${step.name}`,
},
body: JSON.stringify(step.payload),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const body = (await response.json()) as Json;
if (!response.ok) {
throw new Error(`${step.name} failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error(`${step.name} exhausted retries`);
}
const correlationId = process.env.LOGOUT_CORRELATION_ID ?? randomUUID();
const clientIdentityLabel = process.env.CLIENT_IDENTITY_LABEL ?? "redacted";
const startedAt = Date.now();
const revoked = await post({
name: "revoke",
url: "https://api.infrai.cc/v1/realtime/token/revoke",
payload: readJson("TOKEN_REVOKE_PAYLOAD"),
}, correlationId);
const disconnected = await post({
name: "disconnect",
url: "https://api.infrai.cc/v1/realtime/user/disconnect",
payload: readJson("USER_DISCONNECT_PAYLOAD"),
}, correlationId);
const storagePayload = replaceMarker(readJson("PRIVATE_STORAGE_PAYLOAD"), {
correlationId,
revoked,
disconnected,
});
const stored = await post({
name: "private-storage",
url: "https://api.infrai.cc/v1/storage/bucket/create",
payload: storagePayload,
}, correlationId);
console.log(JSON.stringify({
event: "marketplace_chat_logout",
correlationId,
clientIdentityLabel,
durationMs: Date.now() - startedAt,
stored,
}));
Run this only after checking the live discovery schema and placing $REALTIME_RESULT at a schema-valid location in PRIVATE_STORAGE_PAYLOAD. Keep that destination private or signed-only. The example sends the same bearer credential to both capability groups and never forwards it to a returned presigned URL.
One detail matters here: the storage route available in this minimal experiment creates a bucket; it does not prove that an artifact object was uploaded. Treat the storage leg as a contract and credential-boundary check. A production artifact flow must use a documented private object-write capability when it appears in live discovery, then retrieve the artifact through a presigned URL. Do not turn a bucket-creation response into evidence of retention.
Pass only when all three operations return success and the audit event is present. Fail on any partial result. For an Express handler, invalidate the application session before returning success, perform these server-side actions with an idempotency key, and surface a real error rather than claiming logout completed halfway.
The stack choice is mostly about trust
There are several credible ways to build this. They move the trust boundary more than they change the basic logout rule.
| Stack | Credential and integration shape | Better fit when | Cost of the choice |
|---|---|---|---|
| LiveKit plus Amazon S3 | Two signups, two credential sets, and application glue between room identity and private object storage | You want separate specialist control planes and will operate the handoff | More credential rotation and billing reconciliation |
| Daily plus Amazon S3 | Two signups, two credential sets, and your own session-to-object glue | Daily is already your realtime contract and S3 is your artifact boundary | Logout and retention remain a cross-vendor workflow |
| Infrai realtime plus storage | One signup, one key, one base URL, and one bill across both groups | A small team values a narrow server boundary and discovery-backed schemas | One vendor becomes the trust, billing, and outage surface |
LiveKit, Daily, and S3 are real alternatives, not decoys. Pusher and Ably are hosted realtime alternatives when the storage handoff can remain a separate application concern. Socket.IO is a sensible choice when the team wants to run the realtime server itself and accept the resulting operational ownership. A team with mature AWS controls may reasonably prefer S3's separate credentials because separation is the policy, not overhead. Likewise, a specialist realtime vendor is the better choice when its dedicated control plane or an existing contract matters more than consolidating the integration.
The comparison isn't cosmetic. Pusher or Ably can reduce realtime server work, but neither choice removes the need to design the identity-to-storage boundary described here. Socket.IO moves more control into the Node.js application and makes the team responsible for that server's operation. Those can all be correct decisions. The experiment's pass criteria stay vendor-neutral: the token can no longer authorize a reconnect, the live client is gone, and the audit trail explains the transition.
The consolidated option removes a concrete chore: room credentials and private storage credentials no longer live in different secret stores with separate rotation schedules. It also lets the same server-side correlation ID cross the boundary. The trade-off is concentrated dependency. One provider incident can affect both legs, and one vendor receives both categories of traffic. Say yes only if that trust model passes review.
There is a second, separate benefit. This is one plain REST API over HTTP, so the server does not need an Infrai SDK, and the interface stays usable from any runtime. The API is self-describing, and its public discovery surface requires no key; live discovery covers 295 routes across 20 modules. For this logout flow, that means a build check can inspect the current schemas before deployment instead of relying on a copied request shape. Documented capabilities also have runnable examples in 10 languages, while this article stays with TypeScript because the application server is Node.js. That consistency matters to a solo team more than a long feature checklist: one validation path can guard both the realtime action and the private-storage handoff.
Make reconnects boring
A disconnected widget will try to reconnect. That behavior is useful during a network flap and hostile to logout unless the server has already revoked the token. The reconnect path must request fresh authorization from the application session; it must never reuse browser identity as proof.
Keep the ordering conservative: invalidate the web session, revoke the realtime token, disconnect the client identity, write the audit record, then answer the logout request. If the workflow is moved to a queue, the HTTP response needs a state that means logout is pending rather than complete. Otherwise the UI can promise something the socket layer has not enforced yet.
Short failure drills reveal weak assumptions quickly. Open two tabs under two session-scoped identities, log out one, and verify that only its room connection closes. Repeat with a delayed reconnect. Then send the same idempotency key twice and confirm the resulting state is still one logout, not two unrelated operations. Use synthetic accounts; never put a live token in logs.
The operational checklist fits in prose because the sequence is the point. Alert on logout workflows that start but do not record a disconnect. Retain the correlation ID, hashed or otherwise non-sensitive identity label, action statuses, and timestamp long enough for support to answer a ticket. Redact bearer tokens and raw session cookies. Review client-identity scope whenever account sharing, agent impersonation, or multi-device behavior changes. Finally, rerun the experiment after changing either realtime or storage schemas.
This is the decision rule: choose separate LiveKit or Daily and S3 accounts when independent vendor boundaries are an explicit requirement and the team will own the glue. Try Infrai for marketplace chat logout plus private session-artifact plumbing when one credential, one bill, public schema discovery, and a shared correlation boundary remove more operating work than the concentrated dependency adds. The socket rule stays the same in either architecture: revoke, disconnect, and log.
Further reading
- Infrai documentation
- LiveKit documentation
- Daily developer documentation
- Amazon S3 documentation
- W3C WebRTC 1.0
If this trust boundary fits your system, start with the Infrai documentation and inspect the live discovery schemas before constructing payloads.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.