Some applications need to limit users to a single client or browser instance. This post covers how to build, improve, and scale this feature. We begin with a simple web app with two API endpoints: How would we build an experience like the one above? Demo Repo Users log in by sending their user ID in the user HTTP request header to the /login route. Here’s an example request/response: curl -H localhost:9000/login { : } "user:user123" "sessionId" "364rl8" The user adds as an HTTP header for the route . If the session ID is valid, the server returns “authenticated”, if not, the server returns an error: sessionid=364rl8 /api curl -H localhost:9000/api authenticated curl -H localhost:9000/api error: invalid session "sessionid=364rl8" "sessionid=badSession" Note: our example returns the session ID in the HTTP response body, but it’s more common in practice to store the session ID as a cookie, where the server returns the HTTP header. Set-Cookie: sessionid=364rl8 This causes the browser to automatically include the session ID in all subsequent requests to the same domain. 1. The Simplest Solution The simplest solution is to use a server-side session cache that generates and stores a session ID for each user ID. { generateSessionId } = ( ); cors = ( ); app = ( )().use(cors()); PORT = ; sessions = {}; app.get( , (req, res) => { { user } = req.headers; (!user) { res.status( ).send( ); } { sessionId = generateSessionId(); sessions[user] = sessionId; res.send({ sessionId }); } }); app.get( , (req, res) => { { sessionid } = req.headers; (!sessionid) { res.status( ).send( ); } { ( .values(sessions).includes(sessionid)) { res.send( ); } { res.status( ).send( ); } } }); app.listen(PORT, () => { .log( ); }); const require "./utils" const require "cors" const require "express" const 9000 // this will totally scale, trust me const "/login" const if 400 "error: request must include the 'user' HTTP header" else const "/api" const if 401 "error: no sessionId. Log in at /login" else if Object "authenticated" else 401 "error: invalid session." console `server started on http://localhost: ` ${PORT} Whenever a user successfully logs in, the session ID will be overridden. Requests that include outdated session IDs will fail validation, causing the server to return an error. However, if the client does not make an API request, the user will not know that the session was invalidated. Ideally, we want a client-side function that can tell us when the session is no longer valid: async ( ) function logIn userId, onSessionInvalidated The function takes a callback function (as the second argument) that will be invoked whenever we detect that the session is no longer valid. We can implement this API in two ways: polling and server-push. logIn 2. Polling The simplest solution is to use a server-side session cache that generates and stores a session ID for each user ID. { response = fetch( , { : { : userId, }, }); { sessionId } = response.json(); POLLING_INTERVAL = ; poll = setInterval( () => { response = fetch( , { : { sessionId, }, }); (response.status !== ) { clearTimeout(poll); onSessionInvalidated(); } }, POLLING_INTERVAL); sessionId; } async ( ) function logIn userId, onSessionInvalidated const await "http://localhost:9000/login" headers user const await const 200 const async const await "http://localhost:9000/api" headers if 200 // non-200 status code means the token is invalid return However, polling forces us to make a trade-off between latency and efficiency. The shorter the polling interval, the more quickly we can detect a bad session at the cost of more wasted polls. 3. Server Push If we encounter bottlenecks with the polling solution, then our final solution is to maintain a persistent, bi-directional channel on which the server can tell connected clients when their sessions are invalidated. For this demo, we'll use . To host a Web Socket server, we use the . Web Sockets ws package wss = WebSocket.Server({ : }); wss.on( , (ws) => { ws.on( , (data) => { request = .parse(data); (request.action === ) { { sessionId } = request.args; subscribeToSessionInvalidation(sessionId, () => { ws.send( .stringify({ : , : { sessionId, }, }) ); }); } }); }); const new port 9001 "connection" "message" const JSON if "subscribeToSessionInvalidation" const JSON event "sessionInvalidated" args This code tells the server to listen for incoming Web Socket connections on port 9001. For each new connection, listen for messages and assume the following format: { : , : {...} } action "action ID" args If the value is , notify that client whenever the specified session ID is invalidated. action "subscribeToSessionInvalidation" Note: this solution requires generating session IDs that are hard to guess. We also need to update our route handler to detect existing sessions and publish the invalidation event: logIn app.get( , (req, res) => { { user } = req.headers; (!user) { res.status( ).send( ); } { existingSession = sessions[user]; (existingSession) { publishSessionInvalidation(existingSession); } sessionId = generateSessionId(); sessions[user] = sessionId; res.send({ sessionId }); } }); "/login" const if 400 "error: request must include the 'user' HTTP header" else const if const and : subscribeToSessionInvalidation publishSessionInvalidation { EventEmitter } = ( ); sessionEvents = EventEmitter(); SESSION_INVALIDATED = ; { sessionEvents.emit(SESSION_INVALIDATED, sessionId); } { listener = { (sessionId === invalidatedSessionId) { sessionEvents.removeListener(SESSION_INVALIDATED, listener); callback(); } }; sessionEvents.addListener(SESSION_INVALIDATED, listener); } .exports = { publishSessionInvalidation, subscribeToSessionInvalidation, }; const require "events" const new const "session_invalidated" ( ) function publishSessionInvalidation sessionId ( ) function subscribeToSessionInvalidation sessionId, callback const ( ) => invalidatedSessionId if module Now we are ready to update the client to use the to replace our polling logic: WebSocket DOM API { response = fetch( , { : { : userId, }, }); { sessionId } = response.json(); socket = WebSocket( ); socket.addEventListener( , () => { .log( ); socket.addEventListener( , ({ data }) => { { event, args } = .parse(data); (event === ) { onSessionInvalidated(); } }); socket.send( .stringify({ : , : { sessionId, }, }) ); }); socket.addEventListener( , (error) => { .error(error); }); sessionId; } async ( ) function logIn userId, onSessionInvalidated const await "http://localhost:9000/login" headers user const await const new "ws://localhost:9001" "open" console "connected." "message" const JSON if "sessionInvalidated" // args.sessionId should equal sessionId JSON action "subscribeToSessionInvalidation" args "error" console return Load in your browser, and try it out. You should now see some real-time session invalidation action. /push/index.html 4. Scaling I bet you noticed that this solution doesn't scale. We better fix that quickly before our VC pulls their funding! To create a scalable version, we need to make the following changes: Move the session cache to a scalable distributed cache Move from event emitter to a scalable distributed pubsub system Update the client to add retry logic on disconnect satisfies requirements #1 and #2. If we need to scale Redis, we can deploy a Redis or we can use a hosted version of Redis, such as . Redis cluster Amazon ElastiCache Redis as a remote session cache First, let's spin up a redis instance. If you have a docker somewhere: docker run -d -p : redis 6739 6739 Make sure that port 6739 is open if you're running this on a cloud VM. If you don't have a cloud VM, you can launch a as part of the AWS free tier. Once your VM is launched, you can . t2.micro instances on EC2 install docker There are that discuss session caching with Redis; here's my approach, using the : many articles redis npm package redis = ( ); SessionCacheKey = ; client = redis.createClient({ : process.env.REDIS_HOST }); { ( { client.hmget(SessionCacheKey, userId, (err, res) => { resolve(res ? ( .isArray(res) ? res[ ] : res) : ); }); }); } { ( { client.hmset(SessionCacheKey, userId, sessionId, (err, res) => { resolve(res ? ( .isArray(res) ? res[ ] : res) : ); }); }); } // remoteCache.js const require "redis" const "sessions" host async ( ) function getSession userId return new Promise ( ) => resolve return Array 0 null async ( ) function putSession userId, sessionId return new Promise ( ) => resolve Array 0 null We use the Redis commands and (HM stands for "hash map") to respectively read and write the tuple . That takes care of the session storage, we still need to replace event emitter with Redis. The docs state: HMGET HMSET [user ID, session ID] redis npm When a client issues a SUBSCRIBE or PSUBSCRIBE, that connection is put into a "subscriber" mode. At that point, the only valid commands are those that modify the subscription set, and quit (also ping on some redis versions). When the subscription set is empty, the connection is put back into regular mode. So we need to create two Redis clients, one for general commands, the other for dedicated subscriber commands: SessionInvalidationChannel = ; pendingCallbacks = {}; { client = redis.createClient({ : process.env.REDIS_HOST }); subscriber = client.duplicate(); .all([ ( { client.on( , () => resolve()); }), ( { subscriber.on( , () => { subscriber.on( , (channel, invalidatedSession) => { .log(channel, invalidatedSession); ( .keys(pendingCallbacks).includes(invalidatedSession)) { pendingCallbacks[invalidatedSession](); pendingCallbacks[invalidatedSession]; } }); subscriber.subscribe(SessionInvalidationChannel, () => { resolve(); }); }); }), ]); } { client.publish(SessionInvalidationChannel, sessionId); } { pendingCallbacks[sessionId] = callback; } // remoteCache.js const "sessionInvalidation" const async ( ) function connect host // the redis client we're using works in two modes "normal" and // "subscriber". So we duplicate a client here and use that // for our subscriptions. return Promise new Promise ( ) => resolve "ready" new Promise ( ) => resolve "ready" "message" console if Object delete ( ) function publishSessionInvalidation sessionId ( ) function subscribeToSessionInvalidation sessionId, callback In the function, we subscribe to the channel. We publish to this channel when another module calls . connect "sessionInvalidation" publishSessionInvalidation You can run the demo like so: git clone https: cd logged-out/push-redis/server npm i && node server.js //github.com/robzhu/logged-out Next, open in two browser tabs and you should be able to see the working demo. /push-redis/index.html 5. Native Client Let's take a moment to consider example applications that need real-time session invalidation. A few that come to mind for me: games, streaming media clients, advanced finance applications (e.g. bloomberg terminal). Since these sorts of applications are often built as native clients, let's see how a .net client looks: using System; using System.Net.Http; using System.Threading.Tasks; using Newtonsoft.Json; using Websocket.Client; { string LoginEndpoint = ; string UserID = ; Uri WebSocketEndpoint = Uri( ); Task Main(string[] args) { HttpClient client = HttpClient(); client.DefaultRequestHeaders.Add( , UserID); dynamic response = JsonConvert.DeserializeObject( client.GetStringAsync(LoginEndpoint)); string sessionId = response.sessionId; Console.WriteLine( + sessionId); using ( socket = WebsocketClient(WebSocketEndpoint)) { socket.Start(); socket.MessageReceived.Subscribe( { dynamic payload = JsonConvert.DeserializeObject(msg.Text); (payload[ ] == ) { Console.WriteLine( ); Environment.Exit( ); } }); socket.Send(JsonConvert.SerializeObject( { action = , args = { sessionId = sessionId } })); Console.WriteLine( ); Console.ReadLine(); } } } static class Program const "http://localhost:9000/login" const "1234" static new "ws://localhost:9001" static async new "user" await "Obtained session ID: " var new await => msg if "event" "sessionInvalidated" "You have logged in elsewhere. Exiting." 0 new "subscribeToSessionInvalidation" new "Press ENTER to exit." You can run the .net client and web client side by side and watch them invalidate one another. Of the many rough edges in the demo, the lack of type safety around the API stands out to me. Specifically, the topic names and the schema for the subscription request and response. Scaling this solution beyond one developer would require comprehensive documentation or a client-server type system, like a GraphQL schema. Over the course of building this demo, people have suggested several other solutions: ( ) SWR thanks @pacocoursey Pubsub-as-a-service: and pusher pubnub Web Sockets with API Gateway GraphQL Subscriptions Would you like to see a demo of those solutions? I hope this article gave you some ideas for building real-time session invalidation. I'm sure there are great solutions that I haven't considered, please leave them below in the comments. Previously published at https://updateloop.dev/lets-build-you-have-been-logged-out/