Revoke the session token, then disconnect the authenticated client identity before reporting logout as complete. That is the decision rule for a Node.js property-management workspace in 2026: closing a browser connection is insufficient because the same credential may still authorize an open socket, and revoking a credential without evicting that socket may leave an already-connected client visible until some later transport event.
Short answer: logout is a security state transition, not a WebSocket convenience method. The server must bind every realtime token to a real user or client identity, revoke the token, request disconnection by that identity, record the outcome, and make reconnect perform authorization plus backfill from an authoritative cursor. This article treats the logout HTTP response and the online roster as separate consistency boundaries; pretending they commit atomically is how a green dot becomes a false statement.
How Should Node.js Express Force a Client Disconnect on Logout?
For a shared workspace used by property managers, an online indicator can expose more than availability. It can imply that a leasing agent is watching a building queue, that a maintenance coordinator can receive a handoff, or that a departed employee still occupies the workspace. The invariant is therefore blunt: after successful logout, a socket authenticated by the revoked session must neither remain connected nor reconnect with that token.
Three identities need to stay distinct. session_id names the web login, client_id names the realtime principal, and connection_id names one transport instance. Disconnecting only a connection is too narrow when a user has several tabs. Disconnecting only a user without revoking the token is temporary; a client can reconnect. Revoking only the token governs the next authorization check but does not, by itself, express what should happen to a connection that already passed that check.
This produces four operational invariants:
- A realtime token carries a stable, authenticated
client_id; an arbitrary name supplied by the browser is not an identity. - Logout revokes first and disconnects second, so a reconnect racing the eviction cannot regain access with the old token.
- Presence is derived from accepted connections and server-side expiry, never directly from a UI toggle.
- Every disconnect attempt has a correlation record containing the session, client identity, reason, and result, so support can explain why someone disappeared.
The failure boundary matters. The HTTP request can time out after revocation succeeds, and a disconnect request can be retried after the provider already applied it. Both writes need stable idempotency keys. More importantly, logout should not claim success merely because the Express process called socket.close() on the tab that submitted the request. That local handle says nothing about another tab, another Node.js worker, or a reconnect already in flight.
Order matters.
Decision record and provider trade-offs
The architectural choice is provider-neutral: make credential revocation and identity eviction explicit application operations. Products differ in how much of that model they expose, where identity is assigned, and whether the application must build part of the control plane itself. Verify the current plan and product documentation before committing; feature names are less important than the two required semantics.
| Option | Identity and forced-disconnect model | Reconnect and backfill consequence | Boundary to examine |
|---|---|---|---|
| Ably | Token-based authentication can carry a clientId, and its control API documents client revocation |
Resume/recovery behavior still needs an application rule for data outside the recoverable connection window | Confirm that revocation scope matches the session-to-client mapping |
| Pusher Channels | Authenticated users can be terminated through the user termination API | Reconnection must re-enter the application's authorization path; durable business-event backfill remains an application concern | User authentication is required for identity-level termination |
| PubNub | Access Manager tokens govern permissions, while Presence models occupancy and leave behavior | Presence timeout and history/replay semantics must be designed separately rather than treated as one guarantee | Token revocation and immediate occupancy change are different questions |
| Socket.IO | The application can place sockets in a user room and call disconnectSockets() across a compatible adapter |
Connection-state recovery is useful for short interruptions, but durable backfill still needs an authoritative store and cursor | Multi-node behavior depends on the adapter and the application's credential revocation design |
| Infrai | Verified routes support token revocation and disconnect by user identity through one REST API | The application still owns cursor choice, authoritative history, and the rule for rebuilding online state | Use a real identity in the token; do not substitute a browser-generated label |
Infrai is a reasonable fit when the team values one key and one bill across backend services and wants to avoid credentials scattered through many vendor dashboards. Its public discovery surface is self-describing, and the verified platform breadth is 295 routes across 20 modules. The platform convention marks 171 of 294 capabilities as idempotent and specifies a 24-hour default deduplication window. Those are integration advantages, not evidence that presence data can replace an application database, and an application must still preserve its own request ID across ambiguous retries. The same scrutiny applies to every row: ask what is revoked, what is disconnected, and what survives a reconnect.
The critical path is a state machine
The main example below calls the two Infrai operations in the required order. Their request fields are not reproduced here because the live discovery schema is the authority; pass JSON bodies validated against that schema through INFRAI_REVOKE_BODY and INFRAI_DISCONNECT_BODY. This keeps the program runnable without freezing an undocumented payload into an article. INFRAI_BASE_URL must be the service's documented v1 API base.
import json
import os
import time
import urllib.error
import urllib.request
def post(path: str, body: dict, idempotency_key: str) -> dict:
base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
payload = json.dumps(body).encode("utf-8")
for attempt in range(5):
request = urllib.request.Request(
f"{base_url}{path}",
data=payload,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.load(response)
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 4:
raise RuntimeError(f"HTTP {error.code}: {detail}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError("retry loop exhausted")
def main() -> None:
request_id = os.environ["LOGOUT_REQUEST_ID"]
revoke_body = json.loads(os.environ["INFRAI_REVOKE_BODY"])
disconnect_body = json.loads(os.environ["INFRAI_DISCONNECT_BODY"])
revoked = post(
"/realtime/token/revoke",
revoke_body,
f"logout:{request_id}:revoke",
)
print(json.dumps({"stage": "revoked", "response": revoked}))
disconnected = post(
"/realtime/user/disconnect",
disconnect_body,
f"logout:{request_id}:disconnect",
)
print(json.dumps({"stage": "disconnected", "response": disconnected}))
if __name__ == "__main__":
main()
In Express, authenticated middleware should supply session_id and client_id; accepting either value from req.body would let the caller choose the victim. The example uses an explicit POST method, surfaces the actual non-429 response body, retries HTTP 429 with exponential backoff while honoring Retry-After, and sends a stable idempotency key for each write. The environment JSON must be generated from trusted server state, never forwarded from an unvalidated browser body.
Do not collapse the audit trail into one final “logged out” line. A timeout between the two operations is diagnosable only if the record shows that revocation completed and disconnection remained pending. The retry then reuses the same keys.
Short code. Strict contract.
There is also a concurrency wrinkle that deserves a test rather than a comment. Start a reconnect with the old token between revocation and disconnection. The expected result is failed authorization, followed by eviction of any older connection carrying the same authenticated identity. Then repeat logout with the same request ID and verify that no duplicate side effect changes the final state.
How should reconnect and backfill work?
Reconnect should begin from distrust. Authenticate the new token, establish the connection, obtain a server-issued stream position, and request events after the last position the client durably applied. A presence snapshot answers “who is considered online now”; an event backfill answers “what changed while this client was away.” They are not interchangeable.
For the property-management workspace, keep authoritative membership and user status in the application data layer. Treat realtime presence as an expiring projection keyed by workspace and authenticated client identity. On reconnect, fetch a fresh roster snapshot, then apply events newer than the snapshot cursor. If the provider cannot bind the snapshot and cursor consistently, subscribe first, buffer incoming events, fetch the snapshot, discard buffered events at or before its cursor, and then apply the remainder in order.
That ordering closes the familiar gap where an agent comes online after the snapshot query starts but before the subscription becomes active. It also defines duplicate handling: event IDs are unique, cursors move monotonically within their documented scope, and applying an already-seen event is a no-op. None of this makes presence a ledger. It makes the projection repairable.
A disconnect log should include a correlation ID, authenticated client ID, session ID, workspace ID, reason such as user_logout, requested time, completion state, and provider request ID when one is returned. Do not put bearer tokens in it. Support can then distinguish an intentional logout from lease expiry or a network interruption without guessing from browser timestamps.
Rejected option and the case where it is valid
The rejected design is “close the current socket and let presence timeout clean up the rest.” It fails the stated invariant because the credential remains usable, other tabs survive, and the roster can stay stale until timeout. Tightening the timeout only exchanges one inconsistency for more heartbeat traffic and false offline transitions on poor networks.
Local socket closure is still valid for an anonymous, low-risk indicator where no account session exists, no privileged channel is involved, and temporary stale presence has no operational consequence. A public page showing approximate visitor activity may reasonably accept that model. A shared property-management workspace with authenticated staff should not.
The decision can be summarized without vendor language: bind identity at token issuance, revoke before eviction, disconnect by identity, and rebuild the projection from snapshot plus cursor-aware backfill. Anything less leaves logout dependent on transport luck.
References
- Ably token revocation: https://ably.com/docs/auth/revocation
- Pusher Channels terminate user connections: https://pusher.com/docs/channels/server_api/terminating-user-connections/
- PubNub Access Manager revoke token: https://www.pubnub.com/docs/general/security/access-control/revoke-token
- PubNub Presence: https://www.pubnub.com/docs/sdks/javascript/api-reference/presence
- Socket.IO server API: https://socket.io/docs/v4/server-api/
- Socket.IO connection state recovery: https://socket.io/docs/v4/connection-state-recovery
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/
Top comments (0)