Skip to main content
Back to Blog
Socket.ioRedisJWTNode.jsWebSocketsReal-TimeSystem Design

How to Secure and Scale Real-Time Presence with Socket.io, Redis Streams, and JWT Auth in Node.js

Learn how to scale Socket.io horizontally with the @socket.io/redis-streams-adapter, secure WebSocket handshakes using JWT middleware, and build an accurate multi-instance presence dashboard in Node.js.

September 7, 202615 min readNiraj Kumar

Introduction

If you've ever shipped a real-time feature — a "who's online" widget, a collaborative editor, a live chat app — you've probably hit the same wall. It works beautifully on your laptop with one Node.js process. Then you deploy two instances behind a load balancer, and suddenly:

  • Users appear "online" on one tab and "offline" on another.
  • Messages sent from Instance A never reach a socket connected to Instance B.
  • Your presence dashboard flickers between correct and wrong every few seconds.
  • Unauthenticated clients occasionally sneak through because auth checks only ran on the first HTTP request, not the WebSocket upgrade.

None of this is a Socket.io bug. It's what happens when you treat a stateful, persistent-connection protocol like a stateless HTTP request. WebSockets remember who they are. Your infrastructure needs to remember too — across every instance, every restart, and every reconnect.

In this guide, we're going to fix all three problems at once by building a production-grade real-time presence system with:

  • Socket.io v4+ for the WebSocket transport layer and fallback handling
  • @socket.io/redis-streams-adapter to synchronize events and rooms across horizontally scaled Node.js instances
  • JWT-based handshake authentication so only verified users ever complete a socket connection
  • Redis-backed presence counters that stay accurate even when a user has five tabs open across three devices

By the end, you'll have a pattern you can drop into any Node.js backend — whether it's powering a chat app, a multiplayer dashboard, or a live collaboration tool.

Why Scaling Socket.io Is Harder Than It Looks

A WebSocket connection is "sticky" by nature. Once a client connects to Instance A, that connection lives on Instance A for its entire lifetime. If your load balancer round-robins a second request from the same user to Instance B, that instance has no idea the user is already connected somewhere else.

This creates two very different classes of problems:

1. The Broadcast Problem

io.emit() only reaches sockets that are connected to that specific Node.js process, unless you've configured an adapter that fans events out across instances. Without one, a message emitted from Instance A simply never reaches a client on Instance B — even if they're in the "same" room.

2. The State Problem

In-memory data structures (a plain JavaScript Map of userId -> socket, for example) only exist inside a single process's memory. The moment you run more than one instance, or that instance restarts, that state is gone or incomplete. Presence, typing indicators, and "who's in this room" data all fall apart.

Socket.io solves the first problem with adapters — pluggable modules that broadcast events through a shared backend like Redis. We'll solve the second problem ourselves, by treating Redis not just as a message bus, but as the single source of truth for presence state.

Why Redis Streams Instead of the Classic Redis Adapter

For years, the default answer was socket.io-redis (now @socket.io/redis-adapter), which relies on Redis Pub/Sub. Pub/Sub is fast, but it has a structural weakness: if a subscriber is briefly disconnected, any messages published during that gap are lost forever. There's no replay, no backlog, no delivery guarantee.

@socket.io/redis-streams-adapter, maintained by the Socket.io team, replaces Pub/Sub with Redis Streams (XADD / XREAD). This gives you:

  • At-least-once delivery — events are appended to a stream and consumed with cursors, so a momentary network blip doesn't silently drop messages.
  • Redis Cluster compatibility — Pub/Sub doesn't cluster well because messages aren't guaranteed to reach subscribers on every node; Streams handle this more gracefully.
  • Simpler operational model — you don't need a separate publisher and subscriber Redis client; one connection handles both directions.
  • Built-in support for Socket.io's Connection State Recovery, which lets clients seamlessly resume missed events after a brief disconnect (e.g., a mobile device switching from Wi-Fi to cellular).

If you're building anything new in 2026, the Streams adapter is the sensible default over the legacy Pub/Sub adapter.

Architecture Overview

Here's the mental model we're building toward:

        ┌──────────────┐        ┌──────────────┐
        │  Node.js #1  │        │  Node.js #2  │
        │  (Socket.io) │        │  (Socket.io) │
        └──────┬───────┘        └──────┬───────┘
               │                       │
               │   XADD / XREAD        │
               └──────────┬────────────┘
                          │
                    ┌─────▼─────┐
                    │   Redis   │
                    │  Streams  │
                    │  + Hashes │
                    └───────────┘
  • Every Node.js instance connects to the same Redis instance (or cluster).
  • The Redis Streams adapter handles cross-instance event broadcasting (io.emit, rooms, etc.) transparently — your application code doesn't need to know how many instances exist.
  • A Redis hash (presence:online_users) stores the canonical online/offline state, reference-counted by connection, so it's accurate no matter which instance a socket landed on.
  • JWT verification happens in Socket.io middleware, during the handshake, before any application logic runs.

Step 1: Project Setup

Let's scaffold the server dependencies.

mkdir realtime-presence-server && cd realtime-presence-server
npm init -y
npm install express socket.io ioredis jsonwebtoken dotenv
npm install @socket.io/redis-streams-adapter

Your .env file:

PORT=4000
JWT_SECRET=super-long-random-secret-change-me
REDIS_URL=redis://localhost:6379
CLIENT_URL=http://localhost:3000

For local development, spin up Redis quickly with Docker:

docker run -d --name presence-redis -p 6379:6379 redis:7-alpine

Step 2: Authenticating the WebSocket Handshake with JWT

This is the part most tutorials get wrong. A common (and insecure) pattern is to let the socket connect first, then check for a valid session inside the connection event, disconnecting late if the token is bad. That's backwards — by the time you disconnect, the client has already occupied a connection slot, potentially joined rooms, and sent data.

The correct approach is to validate the token inside io.use() middleware, which Socket.io runs during the handshake, before connection ever fires.

// middleware/authenticateSocket.js
import jwt from "jsonwebtoken";

export function authenticateSocket(socket, next) {
  try {
    // Prefer the `auth` payload over query strings or headers —
    // it isn't logged in server access logs or browser history.
    const token = socket.handshake.auth?.token;

    if (!token) {
      return next(new Error("AUTH_ERROR: No token provided"));
    }

    const payload = jwt.verify(token, process.env.JWT_SECRET);

    // Attach a minimal, trusted user object to the socket.
    // Never trust anything the client claims outside of this payload.
    socket.data.user = {
      id: payload.sub,
      name: payload.name,
      role: payload.role ?? "member",
    };

    next();
  } catch (err) {
    if (err.name === "TokenExpiredError") {
      return next(new Error("AUTH_ERROR: Token expired"));
    }
    return next(new Error("AUTH_ERROR: Invalid token"));
  }
}

Wire it into your server:

// server.js
import "dotenv/config";
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";
import { Redis } from "ioredis";
import { createAdapter } from "@socket.io/redis-streams-adapter";
import { authenticateSocket } from "./middleware/authenticateSocket.js";

const app = express();
const httpServer = createServer(app);

const redisClient = new Redis(process.env.REDIS_URL);

const io = new Server(httpServer, {
  cors: {
    origin: process.env.CLIENT_URL,
    credentials: true,
  },
  adapter: createAdapter(redisClient),
  // Restrict to WebSocket only where possible — this avoids the
  // sticky-session requirements that long-polling fallback introduces.
  transports: ["websocket"],
});

// Runs on every handshake, across every instance.
io.use(authenticateSocket);

httpServer.listen(process.env.PORT, () => {
  console.log(`Socket.io server running on port ${process.env.PORT}`);
});

On the client, pass the token through the auth option rather than a query string:

// client.js
import { io } from "socket.io-client";

const socket = io("http://localhost:4000", {
  auth: {
    token: localStorage.getItem("accessToken"),
  },
});

socket.on("connect_error", (err) => {
  if (err.message.startsWith("AUTH_ERROR")) {
    // Redirect to login, refresh the token, etc.
    console.warn("Socket auth failed:", err.message);
  }
});

A few security notes worth internalizing here:

  • Never put JWTs in the connection query string. Query strings get logged by proxies, load balancers, and browser history. The auth handshake payload is not.
  • Keep access tokens short-lived (5–15 minutes) and re-authenticate on reconnect rather than trusting a long-lived token for the socket's entire lifetime.
  • Verify the signature and expiry on every single handshake, even for the same user reconnecting seconds later. Sockets don't inherit trust from prior connections.

Step 3: Configuring the Redis Streams Adapter for Horizontal Scaling

We already wired the adapter into the server above, but it's worth understanding what it actually buys you. Once createAdapter(redisClient) is attached:

  • io.emit(), io.to(room).emit(), and socket.broadcast.emit() all automatically propagate to every connected instance — not just the local process.
  • Room membership (socket.join() / socket.leave()) is synchronized cluster-wide.
  • You get this without writing a single line of custom pub/sub code.

If you're running Redis in cluster mode (recommended once you outgrow a single node), the Streams adapter handles key-slot distribution better than the Pub/Sub adapter, because streams are addressed by explicit keys rather than relying on cluster-wide message fan-out.

One configuration detail that trips people up: give your adapter a dedicated stream name per environment, so staging and production don't collide if they ever share a Redis instance.

const io = new Server(httpServer, {
  adapter: createAdapter(redisClient, {
    streamName: `socket.io-${process.env.NODE_ENV}`,
    maxLen: 10000, // caps stream growth; old entries are trimmed automatically
  }),
});

Step 4: Building a Reliable Presence Tracking System

Here's where most tutorials stop short. Broadcasting events cluster-wide is only half the job — you still need a canonical presence store that isn't tied to any single instance's memory.

The trap: if you track presence with if (userId is in this instance's Map) → online, you'll get wrong answers the moment a second instance joins the picture.

The fix: store presence in Redis itself, as a hash of userId -> connectionCount, incremented on connect and decremented on disconnect. This handles the "add" and "remove" cases atomically and cluster-wide, regardless of which instance the socket is attached to.

// services/presenceService.js
const PRESENCE_KEY = "presence:online_users";

export async function markUserOnline(redis, userId) {
  // HINCRBY is atomic — safe even with concurrent connects
  // from multiple instances for the same user.
  const count = await redis.hincrby(PRESENCE_KEY, userId, 1);
  return count === 1; // true only on the user's FIRST active connection
}

export async function markUserOffline(redis, userId) {
  const count = await redis.hincrby(PRESENCE_KEY, userId, -1);

  if (count <= 0) {
    await redis.hdel(PRESENCE_KEY, userId);
    return true; // true only when the user has NO active connections left
  }
  return false;
}

export async function getOnlineUserIds(redis) {
  return redis.hkeys(PRESENCE_KEY);
}

Now hook it into the connection lifecycle:

// server.js (continued)
import { markUserOnline, markUserOffline, getOnlineUserIds } from "./services/presenceService.js";

io.on("connection", async (socket) => {
  const { id: userId, name } = socket.data.user;

  const justCameOnline = await markUserOnline(redisClient, userId);

  if (justCameOnline) {
    // Only broadcast when this was the user's FIRST connection —
    // avoids spamming "online" events for every extra tab.
    io.emit("presence:update", { userId, name, status: "online" });
  }

  // Send the new client a full snapshot so its UI starts in sync.
  const onlineUserIds = await getOnlineUserIds(redisClient);
  socket.emit("presence:snapshot", onlineUserIds);

  socket.on("disconnect", async () => {
    const wentFullyOffline = await markUserOffline(redisClient, userId);

    if (wentFullyOffline) {
      io.emit("presence:update", { userId, name, status: "offline" });
    }
  });
});

This solves the multi-tab problem elegantly: opening three tabs increments the counter to 3; closing two of them decrements it to 1, and the user correctly stays "online" until the very last connection drops.

Step 5: Handling Multiple Devices and Tabs Per User

The reference-counting pattern above already handles the common case. But two edge cases deserve explicit handling:

Ghost connections after a server crash. If a Node.js process dies without running its disconnect handlers, its sockets' counters never decrement, and users get stuck "online" forever. Mitigate this with a periodic reconciliation job:

// jobs/reconcilePresence.js
export async function reconcilePresence(io, redis) {
  const onlineUserIds = await redis.hkeys("presence:online_users");

  for (const userId of onlineUserIds) {
    const sockets = await io.in(`user:${userId}`).fetchSockets();
    if (sockets.length === 0) {
      await redis.hdel("presence:online_users", userId);
      io.emit("presence:update", { userId, status: "offline" });
    }
  }
}

Run this every 30–60 seconds via setInterval, or better, as a dedicated cron worker so it doesn't compete with request-handling event loop time.

Joining a per-user room. To make the reconciliation query above possible, join every authenticated socket to a room keyed by user ID:

io.on("connection", (socket) => {
  socket.join(`user:${socket.data.user.id}`);
});

This also gives you a clean way to push targeted notifications to all of a user's active devices at once: io.to(\user:$`).emit(...)`.

Step 6: Broadcasting Presence State Across Instances

Because the Redis Streams adapter is already wired in, io.emit("presence:update", ...) from any instance reaches sockets connected to every instance — no additional plumbing required. This is the payoff for the setup work in Step 3: your application code stays blissfully unaware of how many Node.js processes are running behind the load balancer.

Real-World Example: A Team Collaboration Dashboard

Picture a Notion-style collaboration tool where teammates see green dots next to active colleagues' names. With the pattern above:

  1. A user logs in, receives a short-lived JWT from your auth service.
  2. Their client opens a socket connection, passing the JWT in the auth payload.
  3. io.use(authenticateSocket) validates the token during the handshake — no valid token, no connection.
  4. markUserOnline() atomically increments their presence counter in Redis.
  5. Every other connected client — regardless of which Node.js instance they're attached to — receives the presence:update event via the Streams adapter and lights up the green dot.
  6. When the user closes their laptop, the socket disconnects, the counter decrements, and once it hits zero, everyone sees the dot turn gray.

Scale this to 50,000 concurrent users across 20 Node.js instances, and the behavior is identical to the single-instance version you tested locally — that consistency is the entire point of this architecture.

🚀 Pro Tips

  • Use Redis TTLs as a safety net, not a primary mechanism. Set a generous expiry (e.g., 2 minutes) on presence-related keys as a backstop against crashed processes, but rely on reference counting for correctness in the normal case.
  • Emit presence deltas, not full snapshots, for updates. Only send the full online-user list once, on initial connect. After that, emit small { userId, status } deltas to keep bandwidth and client-side diffing minimal.
  • Namespace your Redis keys by environment and tenant. presence:{env}:{tenantId}:online_users prevents cross-environment leakage if staging and production accidentally share infrastructure.
  • Rate-limit reconnect storms. If your client aggressively retries after a token expiry, you can accidentally hammer your auth verification path. Add exponential backoff in socket.io-client's built-in reconnection options.
  • Leverage Connection State Recovery. Socket.io's connectionStateRecovery option lets clients that reconnect within a short grace window automatically re-join rooms and receive missed events — pair it with the Streams adapter for a much smoother mobile experience.
const io = new Server(httpServer, {
  adapter: createAdapter(redisClient),
  connectionStateRecovery: {
    maxDisconnectionDuration: 2 * 60 * 1000,
    skipMiddlewares: false, // keep re-running JWT auth on recovery
  },
});

Best Practices Checklist

  • ✅ Validate JWTs inside io.use() middleware, before connection fires — never after.
  • ✅ Pass tokens via the auth handshake object, never via query strings.
  • ✅ Use short-lived access tokens and re-authenticate on every reconnect.
  • ✅ Store presence state in Redis, reference-counted per user, not per socket.
  • ✅ Use the Redis Streams adapter for reliable, cluster-friendly cross-instance broadcasting.
  • ✅ Join sockets to a per-user room to support multi-device targeting and reconciliation.
  • ✅ Run a periodic reconciliation job to clean up ghost connections after crashes.
  • ✅ Emit incremental presence deltas instead of re-broadcasting full state on every change.
  • ✅ Handle graceful shutdown by decrementing presence counters for all local sockets before exiting.

Common Mistakes to Avoid

  • Authenticating after connection. Checking the JWT inside the connection handler instead of io.use() lets unauthenticated sockets briefly occupy resources and join rooms before you can react.
  • Treating presence as per-socket instead of per-user. Naively emitting "offline" on every disconnect event breaks multi-tab and multi-device usage — a user closing one of five tabs shouldn't look logged out.
  • Using the legacy Pub/Sub Redis adapter for new projects. It works, but it silently drops messages during brief subscriber gaps — a real risk under rolling deployments.
  • Forgetting sticky sessions when long-polling fallback is enabled. If you allow the polling transport, load balancers must route a client's successive HTTP requests to the same instance, or the handshake breaks. Restricting to transports: ["websocket"] sidesteps this entirely for modern clients.
  • Trusting client-supplied user data. Never let the client tell you its own userId or role in the payload — always derive it from the verified JWT claims.
  • Skipping token expiry checks on reconnect. A socket that silently keeps working after its JWT has expired is a security liability, not a convenience.

📌 Key Takeaways

  • Horizontally scaling Socket.io requires solving two separate problems: cross-instance event broadcasting (solved by the Redis Streams adapter) and cross-instance state consistency (solved by storing presence in Redis, not process memory).
  • JWT validation belongs in handshake middleware (io.use()), which runs before any socket is considered connected — this is both more secure and more efficient than post-connection checks.
  • Presence must be reference-counted per user to correctly support multiple tabs and devices without false "offline" flickers.
  • The @socket.io/redis-streams-adapter is the modern, more resilient replacement for the legacy Pub/Sub-based Redis adapter, offering at-least-once delivery and better Redis Cluster support.
  • Periodic reconciliation jobs are essential insurance against ghost presence entries caused by ungraceful process crashes.

Conclusion

Real-time features are deceptively simple to prototype and deceptively hard to run at scale. The gap between "it works on my machine" and "it works across twenty instances behind a load balancer" almost always comes down to two things: where you keep shared state, and when you enforce authentication.

By pairing JWT-validated handshakes with a Redis-backed, reference-counted presence store and the modern Redis Streams adapter, you get a Socket.io deployment that behaves identically whether you're running one instance or a hundred. Your presence dashboard stays accurate, your auth boundary stays tight, and — most importantly — you stop debugging phantom "offline" users at 2 a.m.

From here, natural next steps include adding typing indicators using the same reference-counting pattern, layering in per-room presence for large multi-tenant apps, and instrumenting Redis stream lag as a health metric in your observability stack.

References

Discussion

All Articles
Socket.ioRedisJWTNode.jsWebSocketsReal-TimeSystem Design

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.