Real-Time Notification & Presence System

Build a horizontally-scalable WebSocket server with JWT auth and Redis.

Introduction

30 Second Summary

Think about those green "online" dots in Slack or Discord. Behind each one is a real-time system that tracks thousands of connections, routes messages to specific users, and stays responsive even under heavy load.

In this project, you will build the backend infrastructure that powers real-time notifications and presence tracking. Your system will authenticate users at the transport layer, broadcast online/offline status, and scale horizontally across multiple server instances using Redis as a message bus.

What You'll Build

A production-grade Socket.IO server with JWT authentication, presence tracking, room-based notification routing, and Redis-powered horizontal scaling.

By the end of this project, you'll have:

  • A Socket.IO server with production hardening including pinned transports, payload limits, and heartbeat configuration.
  • JWT authentication that rejects unauthenticated clients before the WebSocket upgrade completes.
  • Real-time presence tracking and room-based notification routing that works across multiple server instances via Redis Pub/Sub.
  • Secret Mission: Implement graceful shutdown with connection draining for zero-downtime rolling deploys.

Are there any prerequisites?

This project assumes familiarity with Node.js and Express. You should also have Docker installed on your machine, since we use it to run Redis locally.

Set Up the Project

Real-time systems need a specific set of infrastructure to function. Your notification server will rely on Node.js for the runtime, Redis as a message bus between server instances, and several production-grade npm packages for WebSocket handling and authentication.

In this step, you will create the project directory, install all dependencies with pinned versions, and spin up a Redis container using Docker Compose.

In this step, get ready to:
  • Initialize the project with Node.js and install all dependencies.
  • Start a Redis container with Docker Compose.
  • Verify that Redis and Node.js are running correctly.
Create the project and install dependencies

Every Node.js project starts with a directory and a package.json file that declares its dependencies and configuration.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir realtime-notification-system
cd realtime-notification-system
  • Initialize the project with npm by running:
npm init -y

What does this command do?

The npm init -y command creates a package.json file with default values. The -y flag accepts all defaults so you skip the interactive prompts.

  • Open the project in VS Code by running:
code .

code command not found?

If code . does not work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, then use File > Open Folder to open the realtime-notification-system folder on your Desktop.

Help me fix the code command

Now you need to configure package.json to use ES modules and add convenience scripts for running the server later.

  • In VS Code, open package.json from the Explorer sidebar on the left.
  • Replace the entire file contents with the following:
{
  "name": "realtime-notification-system",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "start:cluster": "node start-cluster.js",
    "test": "node test-client.js"
  }
}

What does this configuration do?

Setting "type": "module" tells Node.js to treat .js files as ES modules. This lets you use import/export syntax instead of require().

The scripts are shortcuts you will use later. For example, npm start will run node server.js once you create that file.

  • Save package.json.
  • Install all project dependencies by running this command in your terminal:
npm install socket.io@4.8.3 express@4.19.2 jsonwebtoken@9.0.3 redis@5.12.1 @socket.io/redis-adapter@8.3.0 socket.io-client@4.8.3

What are these packages?

  • socket.io is the WebSocket server that handles real-time bidirectional communication.
  • express provides the HTTP server that Socket.IO attaches to.
  • jsonwebtoken handles JWT creation and verification for authenticating connections.
  • redis is the Node.js client for communicating with your Redis container.
  • @socket.io/redis-adapter lets multiple Socket.IO server instances share messages through Redis.
  • socket.io-client is used by the test harness to simulate connected users.
  • Wait for the install to complete. You should see a message indicating packages were added successfully.

Install failed or timed out?

Make sure you are inside the realtime-notification-system folder. Run pwd to confirm your current directory ends with realtime-notification-system.

If you see permission errors, try running the install command again. Network timeouts can happen temporarily.

Help me fix my npm install

After the install completes, npm automatically adds a dependencies section to your package.json. Your file now matches the full target configuration.

✔️ Awesome, I've got everything!

Great. Make sure you have saved package.json and the install completed without errors.

ⓧ I'd like to double check the full code

Your package.json should look exactly like this after the install:

{
  "name": "realtime-notification-system",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "start:cluster": "node start-cluster.js",
    "test": "node test-client.js"
  },
  "dependencies": {
    "@socket.io/redis-adapter": "8.3.0",
    "express": "4.19.2",
    "jsonwebtoken": "9.0.3",
    "redis": "5.12.1",
    "socket.io": "4.8.3",
    "socket.io-client": "4.8.3"
  }
}
Create the Docker Compose file and start Redis

Your server will use Redis as a message bus to coordinate between multiple instances. Instead of installing Redis directly on your machine, you will run it inside a Docker container.

  • In VS Code, create a new file called docker-compose.yml in the root of your realtime-notification-system folder.
  • Paste the following content into docker-compose.yml:
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

What does this configuration do?

  • redis:7-alpine pulls Redis 7 on a lightweight Alpine Linux image.
  • The ports mapping exposes Redis on port 6379 so your Node.js server can connect to it from the host machine.
  • The healthcheck runs redis-cli ping every 5 seconds. Docker marks the container as healthy only when Redis responds.
  • Save docker-compose.yml.
  • Make sure Docker Desktop is running on your machine. If it is not running, open it now before proceeding.
  • Start the Redis container by running this command in your terminal:
docker compose up -d

What does this command do?

The -d flag runs the container in detached mode. This means Redis runs in the background so your terminal stays free for other commands.

  • You should see output indicating that the Redis container was created and started.

Docker command failed?

If you see an error about the Docker daemon not running, open Docker Desktop and wait for it to fully start before trying the command again.

If port 6379 is already in use, you may have another Redis instance running. Stop it first, or check for other services using that port.

Help me fix Docker

✔️ Awesome, I've got everything!

Great. Double check you have saved both package.json and docker-compose.yml.

ⓧ I'd like to double check the full code

Your docker-compose.yml should look exactly like this:

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

Your package.json should look exactly like this:

{
  "name": "realtime-notification-system",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "start:cluster": "node start-cluster.js",
    "test": "node test-client.js"
  },
  "dependencies": {
    "@socket.io/redis-adapter": "8.3.0",
    "express": "4.19.2",
    "jsonwebtoken": "9.0.3",
    "redis": "5.12.1",
    "socket.io": "4.8.3",
    "socket.io-client": "4.8.3"
  }
}
Verify Redis and Node.js are ready

Before writing any server code, confirm that your infrastructure is healthy and your Node.js version meets the project requirements.

  • Check that Redis is running and healthy by running:
docker compose ps
  • You should see the Redis container listed with a status of Up and (healthy). If the health status has not appeared yet, wait a few seconds and run the command again.
  • Verify your Node.js version by running:
node --version

This project requires Node.js 18 or higher.

✔️ I see version 18 or higher

Your output shows something like v18.x.x, v20.x.x, or v22.x.x. You are good to go.

ⓧ I see a version lower than 18

You have Node.js installed but need a newer version. The recommended way to manage Node.js versions is with nvm (Node Version Manager).

  • If you have nvm installed, upgrade by running:
nvm install 18
nvm use 18
  • Verify the new version by running node --version again. It should now show v18 or higher.
  • If you do not have nvm, visit the official Node.js downloads page at https://nodejs.org and install the LTS version (18 or higher).

ⓧ Command not found

Node.js is not installed or not in your PATH. Visit https://nodejs.org and download the LTS installer for your operating system.

  • After installing, close and reopen your terminal.
  • Run node --version again to confirm it shows v18 or higher.

Your environment is ready. You have a Node.js project with production dependencies installed and a Redis message bus running in Docker. Next up, you will build the core Socket.IO server with production-hardened configuration.

Build the Core Server

Your environment is ready with Node.js, your packages, and Redis running in Docker. Now it's time to build the core real-time server that every subsequent feature plugs into.

In this step, you'll create an Express-backed Socket.IO server with production-grade configuration. This means pinning transports to skip slow fallbacks, enforcing payload limits, and tuning heartbeat intervals so the server detects dead connections quickly.

In this step, get ready to:
  • Create a Socket.IO server with hardened production options.
  • Add structured logging for connection lifecycle events.
  • Build a health check endpoint and verify the server responds.
Create the server with production Socket.IO config

A production WebSocket server needs more than a default setup. Pinning transports to websocket skips the HTTP long-polling handshake phase, reducing latency and removing the need for sticky sessions behind a load balancer.

  • Create a file called server.js in your realtime-notification-system folder and paste the following code:
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";

const PORT = process.env.PORT || 3000;

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

app.use(express.json());

// Socket.IO server with production-grade options
const io = new Server(httpServer, {
  // Skip HTTP long-polling, connect via WebSocket directly
  transports: ["websocket"],
  // Limit payload size to 1MB to prevent abuse
  maxHttpBufferSize: 1e6,
  // Send a ping every 25 seconds to detect dead connections
  pingInterval: 25000,
  // Wait 20 seconds for a pong before closing the connection
  pingTimeout: 20000,
  // Allow local development connections
  cors: {
    origin: process.env.NODE_ENV === "production" ? false : ["http://localhost:3000"],
  },
});

What does this code do?

This sets up the foundation layer that every real-time feature will build on.

  • transports: ["websocket"] forces clients to connect over WebSocket directly, bypassing the default long-polling upgrade cycle.
  • maxHttpBufferSize: 1e6 caps incoming payloads at 1MB so a single client cannot exhaust server memory.
  • pingInterval and pingTimeout work together to detect zombie connections. If a client does not respond to a ping within 20 seconds, the server closes the socket.
  • The cors config blocks cross-origin requests in production but allows them during local development.

Why pin transports to WebSocket only?

By default, Socket.IO starts every connection with HTTP long-polling and then upgrades to WebSocket. This fallback helps on corporate proxies that block WebSocket, but it doubles HTTP volume and forces you to use sticky sessions behind a load balancer.

Pinning to websocket eliminates both problems. Since your system runs on infrastructure you control, the long-polling fallback adds cost without benefit.

  • Save server.js.

You won't see a change yet. The next chunk adds connection logging and makes the server startable.

Add connection logging and the health endpoint

Structured logging tells you exactly who connected, who dropped, and why. A health endpoint gives load balancers and monitoring tools a way to check if the server is alive.

  • In server.js, add the connection handler below the const io = new Server(...) block:
// Log every connection lifecycle event
io.on("connection", (socket) => {
  console.log(`[Server:${PORT}] Socket connected: ${socket.id}`);

  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] Socket disconnected: ${socket.id} (${reason})`);
  });

  socket.on("error", (err) => {
    console.error(`[Server:${PORT}] Socket error: ${err.message}`);
  });
});

What does this code do?

Every time a client connects, the server logs their unique socket ID. When they disconnect, the log includes the reason (e.g., "transport close", "ping timeout") so you can diagnose issues without guessing.

The error listener catches socket-level errors that would otherwise fail silently.

  • Below the connection handler, add the health endpoint and the server startup:
// Health check endpoint for monitoring and load balancers
app.get("/health", async (req, res) => {
  const sockets = await io.fetchSockets();
  res.json({
    status: "ok",
    port: PORT,
    connections: sockets.length,
    uptime: process.uptime(),
  });
});

// Start listening for connections
httpServer.listen(PORT, () => {
  console.log(`[Server:${PORT}] Real-time notification server running on http://localhost:${PORT}`);
});

What does this code do?

io.fetchSockets() returns an array of all currently connected sockets. The health endpoint exposes this count alongside the server's uptime, giving you a lightweight dashboard without any external tooling.

httpServer.listen() starts the HTTP server that both Express routes and WebSocket connections share.

  • Save server.js.
  • Start the server by running:
node server.js

You should see this message in your terminal:

[Server:3000] Real-time notification server running on http://localhost:3000

Don't see the startup message?

  • Check that you saved server.js in the realtime-notification-system folder, not a parent or subfolder.
  • If you see SyntaxError: Cannot use import statement, confirm your package.json has "type": "module".
  • If port 3000 is already in use, stop whatever process is using it or set a different port with PORT=3001 node server.js.

help me debug my server startup error

Verify the health endpoint and WebSocket connections

A running server is only useful if it actually responds to requests and accepts connections. You'll hit the health endpoint and then confirm WebSocket connections work.

  • Open a second terminal (keep the server running in the first one).
  • Test the health endpoint by running:
curl http://localhost:3000/health

You should see a JSON response like this:

{
  "status": "ok",
  "port": 3000,
  "connections": 0,
  "uptime": 5.123
}

What should I see?

connections: 0 is correct because no WebSocket clients have connected yet. The uptime value shows how many seconds your server has been running.

  • Verify WebSocket connections work by running this quick test in the same second terminal:
node -e "import('socket.io-client').then(({io}) => { const s = io('http://localhost:3000', {transports:['websocket']}); s.on('connect', () => { console.log('Connected:', s.id); s.disconnect(); process.exit(0); }); s.on('connect_error', (e) => { console.log('Error:', e.message); process.exit(1); }); })"

You should see a socket ID printed (like Connected: abc123XYZ) and in your server terminal, the connect and disconnect logs appear.

  • Switch back to the server terminal. You should see two new log lines:
[Server:3000] Socket connected: <socket-id>
[Server:3000] Socket disconnected: <socket-id> (client namespace disconnect)

Connection test failing?

  • Make sure the server is still running in the first terminal. If it crashed, restart it with node server.js.
  • If you see a "fetch is not defined" error, confirm your Node.js version is 18 or higher with node --version.
  • On Windows, if curl returns HTML instead of JSON, use curl.exe instead (PowerShell aliases curl to a different command).

help me debug my WebSocket connection test

  • Stop the server by pressing Ctrl+C in the server terminal.

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js and confirmed both the health endpoint and WebSocket connections work.

ⓧ I'd like to double check the full code

Here is the complete server.js at this point:

import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";

const PORT = process.env.PORT || 3000;

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

app.use(express.json());

const io = new Server(httpServer, {
  transports: ["websocket"],
  maxHttpBufferSize: 1e6,
  pingInterval: 25000,
  pingTimeout: 20000,
  cors: {
    origin: process.env.NODE_ENV === "production" ? false : ["http://localhost:3000"],
  },
});

io.on("connection", (socket) => {
  console.log(`[Server:${PORT}] Socket connected: ${socket.id}`);

  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] Socket disconnected: ${socket.id} (${reason})`);
  });

  socket.on("error", (err) => {
    console.error(`[Server:${PORT}] Socket error: ${err.message}`);
  });
});

app.get("/health", async (req, res) => {
  const sockets = await io.fetchSockets();
  res.json({
    status: "ok",
    port: PORT,
    connections: sockets.length,
    uptime: process.uptime(),
  });
});

httpServer.listen(PORT, () => {
  console.log(`[Server:${PORT}] Real-time notification server running on http://localhost:${PORT}`);
});

Your core server is production-ready with hardened configuration, structured logging, and a health endpoint. Next up, you'll add JWT authentication so the server rejects unauthenticated clients before the WebSocket upgrade even completes.

Add JWT Authentication

Your server accepts any WebSocket connection right now. In production, you need to reject unauthorized clients before the connection upgrades from HTTP to WebSocket. This prevents unauthenticated users from consuming server resources.

In this step, you will build a JWT-based authentication layer that verifies tokens during the Socket.IO handshake. The server will reject invalid tokens immediately, and authenticated clients will have their identity attached to the socket for the rest of the session.

In this step, get ready to:
  • Create an auth module with token generation, verification, and middleware.
  • Wire the auth middleware into the Socket.IO server and add a token endpoint.
  • Test that authenticated connections succeed and unauthenticated ones are rejected.
Create the authentication module

The auth module handles three things: generating JWTs for testing, verifying incoming tokens, and acting as Socket.IO middleware that gates every connection.

  • Create a new file called auth.js in your project folder.
  • Add the token generation and verification functions by pasting this code:
import jwt from "jsonwebtoken";

const JWT_SECRET = "your-secret-key-here";
const TOKEN_EXPIRY = "1h";

export function generateToken(userId, username) {
  return jwt.sign({ userId, username }, JWT_SECRET, {
    expiresIn: TOKEN_EXPIRY,
  });
}

export function verifyToken(token) {
  return jwt.verify(token, JWT_SECRET);
}

What does this code do?

The JWT_SECRET is a signing key that both creates and validates tokens. In production, this would be an environment variable. Here we use a hardcoded string for local testing.

The generateToken function packs a userId and username into a signed JWT that expires in 1 hour. The verifyToken function checks the signature and expiry, throwing an error if either is invalid.

You won't see a change yet. The next code block adds the middleware that uses these functions.

  • Add the authentication middleware below the verifyToken function in auth.js:
export function authMiddleware(socket, next) {
  const token = socket.handshake.auth.token;

  if (!token) {
    return next(new Error("authentication_error"));
  }

  try {
    const decoded = verifyToken(token);
    socket.data.userId = decoded.userId;
    socket.data.username = decoded.username;
    next();
  } catch (err) {
    next(new Error("authentication_error"));
  }
}

What does this middleware do?

Socket.IO middleware runs before the connection event fires. This function intercepts every incoming connection attempt at the handshake stage.

It extracts the token from socket.handshake.auth.token (a field clients set during connection). If the token is missing or invalid, the middleware calls next(new Error("authentication_error")) which rejects the connection entirely. The client never gets a connect event. It receives a connect_error instead.

If the token is valid, the decoded userId and username are attached to socket.data. This makes the identity available to all subsequent event handlers without re-verifying.

  • Save auth.js.

Seeing a syntax error?

Make sure the authMiddleware function is placed after the verifyToken function. The middleware calls verifyToken internally, so it must be defined below it in the file.

Also confirm that all three functions have the export keyword. Without it, server.js won't be able to import them.

help me debug my auth.js file

✔️ Awesome, I've got everything!

Great. Double check you have saved auth.js.

ⓧ I'd like to double check the full code

import jwt from "jsonwebtoken";

const JWT_SECRET = "your-secret-key-here";
const TOKEN_EXPIRY = "1h";

export function generateToken(userId, username) {
  return jwt.sign({ userId, username }, JWT_SECRET, {
    expiresIn: TOKEN_EXPIRY,
  });
}

export function verifyToken(token) {
  return jwt.verify(token, JWT_SECRET);
}

export function authMiddleware(socket, next) {
  const token = socket.handshake.auth.token;

  if (!token) {
    return next(new Error("authentication_error"));
  }

  try {
    const decoded = verifyToken(token);
    socket.data.userId = decoded.userId;
    socket.data.username = decoded.username;
    next();
  } catch (err) {
    next(new Error("authentication_error"));
  }
}
Wire authentication into the server

Now that the auth module exists, you need to plug it into your server. This involves importing the module, registering the middleware, and updating your connection handler to use the authenticated identity.

  • In server.js, find the imports at the top of the file. Find this line:
import { Server } from "socket.io";
  • Add this import directly below it:
import { generateToken, authMiddleware } from "./auth.js";
  • Next, find the closing }); of the new Server(httpServer, {...}) block. Add this line directly below it:
io.use(authMiddleware);

What does io.use() do?

The io.use() method registers middleware that runs on every incoming connection attempt. By placing authMiddleware here, every socket must present a valid JWT before the connection event fires. No valid token means no connection.

  • Now update the connection handler to use the authenticated identity. Find this code in server.js:
io.on("connection", (socket) => {
  console.log(`[Server:${PORT}] Socket connected: ${socket.id}`);

  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] Socket disconnected: ${socket.id} (${reason})`);
  });
  • Replace those lines with this updated version that reads from socket.data:
io.on("connection", (socket) => {
  const { userId, username } = socket.data;
  console.log(`[Server:${PORT}] User connected: ${username} (${userId}) | Socket: ${socket.id}`);

  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] User disconnected: ${username} (${reason})`);
  });

Why destructure socket.data?

The auth middleware attached userId and username to socket.data during the handshake. By destructuring at the top of the connection handler, every subsequent event handler has direct access to the user's identity without re-parsing the token.

  • Finally, add a POST /auth/token endpoint that issues test JWTs. Find the app.get("/health" endpoint in server.js and paste this new endpoint directly above it:
app.post("/auth/token", (req, res) => {
  const { userId, username } = req.body;
  if (!userId || !username) {
    return res.status(400).json({ error: "userId and username are required" });
  }
  const token = generateToken(userId, username);
  res.json({ token });
});

What does this endpoint do?

This simulates your real auth service (like Auth0 or Firebase Auth). In production, token issuance happens through your identity provider. Here we expose a simple HTTP endpoint so our test client can request tokens programmatically.

The endpoint requires both userId and username in the request body. It returns a signed JWT the client can use to authenticate its WebSocket connection.

  • Save server.js.
  • Restart your server by pressing Ctrl+C in the terminal where it is running, then run:
node server.js

You should see the server start message again. The server now rejects any WebSocket connection that does not present a valid JWT.

Server won't start?

Check that the import line at the top matches exactly: import { generateToken, authMiddleware } from "./auth.js";. The ./ prefix is required for local file imports in ES modules.

If you see SyntaxError: Unexpected token, confirm your package.json still has "type": "module".

help me fix my server.js import or startup error

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and that it restarted without errors.

ⓧ I'd like to double check the full code

import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { generateToken, authMiddleware } from "./auth.js";

const PORT = process.env.PORT || 3000;

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

app.use(express.json());

const io = new Server(httpServer, {
  transports: ["websocket"],
  maxHttpBufferSize: 1e6,
  pingInterval: 25000,
  pingTimeout: 20000,
  cors: {
    origin: process.env.NODE_ENV === "production" ? false : ["http://localhost:3000"],
  },
});

io.use(authMiddleware);

io.on("connection", (socket) => {
  const { userId, username } = socket.data;
  console.log(`[Server:${PORT}] User connected: ${username} (${userId}) | Socket: ${socket.id}`);

  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] User disconnected: ${username} (${reason})`);
  });

  socket.on("error", (err) => {
    console.error(`[Server:${PORT}] Socket error: ${err.message}`);
  });
});

app.post("/auth/token", (req, res) => {
  const { userId, username } = req.body;
  if (!userId || !username) {
    return res.status(400).json({ error: "userId and username are required" });
  }
  const token = generateToken(userId, username);
  res.json({ token });
});

app.get("/health", async (req, res) => {
  const sockets = await io.fetchSockets();
  res.json({
    status: "ok",
    port: PORT,
    connections: sockets.length,
    uptime: process.uptime(),
  });
});

httpServer.listen(PORT, () => {
  console.log(`[Server:${PORT}] Real-time notification server running on http://localhost:${PORT}`);
});
Create the test client and verify authentication

The best way to confirm your auth layer works is to connect a valid client and an invalid client simultaneously. The valid client should connect successfully. The invalid client should be rejected with a connect_error event.

  • Create a new file called test-client.js in your project folder.
  • Add the token-fetching helper by pasting this code:
import { io } from "socket.io-client";

const SERVER_1 = "http://localhost:3000";

async function getToken(server, userId, username) {
  const response = await fetch(`${server}/auth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const data = await response.json();
  return data.token;
}

What does getToken do?

This function calls the POST /auth/token endpoint you just created. It sends a userId and username, receives a signed JWT in return, and passes it back to the caller. This simulates a client logging in through your auth service before opening a WebSocket.

You won't see a change yet. The next block adds the connection helper.

  • Add the connection helper function below getToken in test-client.js:
function connectUser(server, token, label) {
  const socket = io(server, {
    auth: { token },
    transports: ["websocket"],
  });

  socket.on("connect", () => {
    console.log(`[${label}] Connected to ${server} | Socket: ${socket.id}`);
  });

  socket.on("connect_error", (err) => {
    console.log(`[${label}] Connection error: ${err.message}`);
  });

  return socket;
}

How does the client send its token?

The auth: { token } option passes the JWT during the Socket.IO handshake. This is the value your authMiddleware reads from socket.handshake.auth.token on the server side.

The connect_error listener fires when the server's middleware rejects the connection. Socket.IO does not automatically retry connections rejected by middleware, so the client logs the error and stops.

You won't see a change yet. The next block adds the main test function.

  • Add the main test function at the bottom of test-client.js:
async function main() {
  console.log("--- Auth Test ---\n");

  const tokenAlice = await getToken(SERVER_1, "user-1", "Alice");
  const alice = connectUser(SERVER_1, tokenAlice, "Alice");

  // Test rejected connection
  const rejected = connectUser(SERVER_1, "invalid-token", "Rejected");

  await new Promise((resolve) => setTimeout(resolve, 2000));

  alice.disconnect();
  rejected.disconnect();
  process.exit(0);
}

main().catch(console.error);

What does the test prove?

The test connects two clients simultaneously. Alice gets a valid JWT from the token endpoint and connects successfully. The rejected client passes a garbage string as its token, which fails JWT verification and triggers a connect_error event.

The 2-second timeout gives both connections time to resolve before cleaning up. This confirms your auth middleware correctly separates valid users from impostors.

  • Save test-client.js.
  • Make sure your server is still running in another terminal. Open a new terminal and run the test:
node test-client.js

You should see Alice connect successfully and the rejected client receive a connection error with the message authentication_error.

Not seeing the expected output?

If you see fetch is not defined, make sure you are running Node.js 18 or higher. The global fetch API was added in Node 18.

If Alice also gets a connection error, check that server.js has app.use(express.json()) before the POST /auth/token route. Without the JSON body parser, the server cannot read the userId and username from the request.

help me debug my test client connection errors

✔️ Awesome, I've got everything!

Great. Double check you have saved test-client.js.

ⓧ I'd like to double check the full code

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

const SERVER_1 = "http://localhost:3000";

async function getToken(server, userId, username) {
  const response = await fetch(`${server}/auth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const data = await response.json();
  return data.token;
}

function connectUser(server, token, label) {
  const socket = io(server, {
    auth: { token },
    transports: ["websocket"],
  });

  socket.on("connect", () => {
    console.log(`[${label}] Connected to ${server} | Socket: ${socket.id}`);
  });

  socket.on("connect_error", (err) => {
    console.log(`[${label}] Connection error: ${err.message}`);
  });

  return socket;
}

async function main() {
  console.log("--- Auth Test ---\n");

  const tokenAlice = await getToken(SERVER_1, "user-1", "Alice");
  const alice = connectUser(SERVER_1, tokenAlice, "Alice");

  // Test rejected connection
  const rejected = connectUser(SERVER_1, "invalid-token", "Rejected");

  await new Promise((resolve) => setTimeout(resolve, 2000));

  alice.disconnect();
  rejected.disconnect();
  process.exit(0);
}

main().catch(console.error);

Your server now rejects unauthorized connections at the handshake. No unauthenticated client can consume server resources or listen to events. Next up, you will build presence tracking and room-based notification routing on top of this authenticated foundation.

Implement Presence Tracking and Notifications

Your server authenticates users, but it has no idea who is currently online or how to route messages between them. In a production system like Slack or Discord, the moment a user connects, every other user knows about it instantly.

In this step, you will build presence tracking and notification routing. The server will maintain a live map of connected users, broadcast online/offline status changes, and deliver targeted or broadcast notifications using Socket.IO rooms.

In this step, get ready to:
  • Track connected users with an in-memory presence map and broadcast online/offline events.
  • Route targeted and broadcast notifications through user-specific rooms.
  • Expose a presence HTTP endpoint and verify everything with a multi-user test harness.
Join rooms and track presence on connect

Socket.IO rooms are lightweight groups that sockets can join. By placing each user into a room named user:{userId}, you create a direct channel to reach them. A shared presence room lets you broadcast status changes to everyone at once.

  • In server.js, find this line above io.use(authMiddleware);:
});
  • Add the in-memory presence map between that closing }); of the Server constructor and io.use(authMiddleware);:
// In-memory presence map
const presenceMap = new Map();

What is a presence map?

The presence map stores which users are currently connected. Each entry maps a userId to their username and connection timestamp. This gives the server instant lookup without querying sockets.

  • Now find the inside of the io.on("connection", ...) handler. Locate this existing log line:
  console.log(`[Server:${PORT}] User connected: ${username} (${userId}) | Socket: ${socket.id}`);
  • Add the room joins and presence broadcast directly below that log line:
  // Join user-specific room and presence room
  socket.join(`user:${userId}`);
  socket.join("presence");

  // Add to presence store and broadcast
  presenceMap.set(userId, { username, connectedAt: Date.now() });
  io.to("presence").emit("user:online", { userId, username });

What does this code do?

socket.join(`user:${userId}`) places this socket into a private room. Any message sent to that room reaches only this user, even if they have multiple tabs open.

socket.join("presence") adds them to the shared presence room. The io.to("presence").emit(...) call then broadcasts the online event to every connected user simultaneously.

  • Save server.js.

Seeing a syntax error when saving?

Make sure the new lines sit inside the io.on("connection", (socket) => { ... }) callback, not outside it. The backtick strings use template literals. Check that you have backticks (`) and not regular quotes.

Help me fix a syntax error in my Socket.IO connection handler

Handle notifications and disconnect

With rooms in place, you can now route messages. A targeted notification goes to one user's room. A broadcast notification goes to the entire presence room.

  • In server.js, find the socket.on("error", ...) handler inside the connection callback:
  socket.on("error", (err) => {
    console.error(`[Server:${PORT}] Socket error: ${err.message}`);
  });
  • Delete the entire error handler block. You will not need it for this project.
  • Now find the existing disconnect handler:
  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] User disconnected: ${username} (${reason})`);
  });
  • Replace that disconnect handler with the following updated version, and add the notification handlers directly above it:
  // Handle targeted notification
  socket.on("notification:send", (data) => {
    const { targetUserId, title, body } = data;
    io.to(`user:${targetUserId}`).emit("notification:receive", {
      from: { userId, username },
      title,
      body,
      timestamp: Date.now(),
    });
    console.log(`[Server:${PORT}] Notification: ${username} -> user:${targetUserId}`);
  });

  // Handle broadcast notification
  socket.on("notification:broadcast", (data) => {
    const { title, body } = data;
    io.to("presence").emit("notification:receive", {
      from: { userId, username },
      title,
      body,
      timestamp: Date.now(),
    });
    console.log(`[Server:${PORT}] Broadcast notification from ${username}`);
  });

  // Handle disconnect
  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] User disconnected: ${username} (${reason})`);
    presenceMap.delete(userId);
    io.to("presence").emit("user:offline", { userId, username });
  });

What does this code do?

notification:send takes a targetUserId and routes the payload to that user's private room. Only sockets in user:{targetUserId} receive it.

notification:broadcast emits to the entire presence room, reaching every authenticated user at once.

On disconnect, the server removes the user from the presence map and broadcasts user:offline so all remaining clients know immediately.

  • Save server.js.

Unsure where the notification handlers go?

All three handlers (notification:send, notification:broadcast, and disconnect) must be inside the io.on("connection", (socket) => { ... }) callback, after the io.to("presence").emit("user:online", ...) line you added in the previous substep.

Help me position my Socket.IO event handlers correctly

Add the presence endpoint and multi-user test

The server tracks presence internally, but external services (dashboards, monitoring) also need to query who is online. An HTTP endpoint exposes this data without requiring a WebSocket connection.

  • In server.js, find the app.get("/health", ...) endpoint. Add the presence endpoint directly below it (after its closing });):
app.get("/presence", (req, res) => {
  const online = {};
  for (const [userId, data] of presenceMap.entries()) {
    online[userId] = data;
  }
  res.json({ online });
});

What does this code do?

This iterates over the in-memory presenceMap and converts it to a plain object for JSON serialization. Any HTTP client can call GET /presence to see who is connected right now.

  • Save server.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js before moving on.

ⓧ I'd like to double check the full code

import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { generateToken, authMiddleware } from "./auth.js";

const PORT = process.env.PORT || 3000;

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

app.use(express.json());

const io = new Server(httpServer, {
  transports: ["websocket"],
  maxHttpBufferSize: 1e6,
  pingInterval: 25000,
  pingTimeout: 20000,
  cors: {
    origin: process.env.NODE_ENV === "production" ? false : ["http://localhost:3000"],
  },
});

// In-memory presence map
const presenceMap = new Map();

io.use(authMiddleware);

io.on("connection", (socket) => {
  const { userId, username } = socket.data;

  console.log(`[Server:${PORT}] User connected: ${username} (${userId}) | Socket: ${socket.id}`);

  socket.join(`user:${userId}`);
  socket.join("presence");

  presenceMap.set(userId, { username, connectedAt: Date.now() });
  io.to("presence").emit("user:online", { userId, username });

  socket.on("notification:send", (data) => {
    const { targetUserId, title, body } = data;
    io.to(`user:${targetUserId}`).emit("notification:receive", {
      from: { userId, username },
      title,
      body,
      timestamp: Date.now(),
    });
    console.log(`[Server:${PORT}] Notification: ${username} -> user:${targetUserId}`);
  });

  socket.on("notification:broadcast", (data) => {
    const { title, body } = data;
    io.to("presence").emit("notification:receive", {
      from: { userId, username },
      title,
      body,
      timestamp: Date.now(),
    });
    console.log(`[Server:${PORT}] Broadcast notification from ${username}`);
  });

  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] User disconnected: ${username} (${reason})`);
    presenceMap.delete(userId);
    io.to("presence").emit("user:offline", { userId, username });
  });
});

app.post("/auth/token", (req, res) => {
  const { userId, username } = req.body;
  if (!userId || !username) {
    return res.status(400).json({ error: "userId and username are required" });
  }
  const token = generateToken(userId, username);
  res.json({ token });
});

app.get("/health", async (req, res) => {
  const sockets = await io.fetchSockets();
  res.json({
    status: "ok",
    port: PORT,
    connections: sockets.length,
    uptime: process.uptime(),
  });
});

app.get("/presence", (req, res) => {
  const online = {};
  for (const [userId, data] of presenceMap.entries()) {
    online[userId] = data;
  }
  res.json({ online });
});

httpServer.listen(PORT, () => {
  console.log(`[Server:${PORT}] Real-time notification server running on http://localhost:${PORT}`);
});

Now update the test client to exercise presence tracking and notification routing with multiple users.

  • Replace the entire contents of test-client.js with the following multi-user test harness:
import { io } from "socket.io-client";

const SERVER_1 = "http://localhost:3000";

async function getToken(server, userId, username) {
  const response = await fetch(`${server}/auth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const data = await response.json();
  return data.token;
}

function connectUser(server, token, label) {
  const socket = io(server, {
    auth: { token },
    transports: ["websocket"],
  });

  socket.on("connect", () => {
    console.log(`[${label}] Connected to ${server} | Socket: ${socket.id}`);
  });

  socket.on("connect_error", (err) => {
    console.log(`[${label}] Connection error: ${err.message}`);
  });

  socket.on("user:online", (data) => {
    console.log(`[${label}] User came online: ${data.username}`);
  });

  socket.on("user:offline", (data) => {
    console.log(`[${label}] User went offline: ${data.username}`);
  });

  socket.on("notification:receive", (data) => {
    console.log(`[${label}] Notification from ${data.from.username}: ${data.title} - ${data.body}`);
  });

  return socket;
}

async function main() {
  console.log("--- Presence & Notification Test ---\n");

  const tokenAlice = await getToken(SERVER_1, "user-1", "Alice");
  const tokenBob = await getToken(SERVER_1, "user-2", "Bob");
  const tokenCharlie = await getToken(SERVER_1, "user-3", "Charlie");

  const alice = connectUser(SERVER_1, tokenAlice, "Alice");
  const bob = connectUser(SERVER_1, tokenBob, "Bob");
  const charlie = connectUser(SERVER_1, tokenCharlie, "Charlie");

  await new Promise((resolve) => setTimeout(resolve, 2000));

  alice.emit("notification:send", {
    targetUserId: "user-3",
    title: "Hey Charlie",
    body: "Direct message from Alice!",
  });

  await new Promise((resolve) => setTimeout(resolve, 1000));

  bob.emit("notification:broadcast", {
    title: "System Alert",
    body: "Scheduled maintenance in 30 minutes",
  });

  await new Promise((resolve) => setTimeout(resolve, 1000));

  const presenceRes = await fetch(`${SERVER_1}/presence`);
  const presence = await presenceRes.json();
  console.log("Online users:", JSON.stringify(presence, null, 2));

  alice.disconnect();
  await new Promise((resolve) => setTimeout(resolve, 1000));

  bob.disconnect();
  charlie.disconnect();
  process.exit(0);
}

main().catch(console.error);

What does this test do?

The test connects three users (Alice, Bob, Charlie) and listens for presence events. Each user logs when others come online or go offline.

Alice sends a targeted notification to Charlie (only Charlie receives it). Bob sends a broadcast that reaches everyone. The test then queries the /presence endpoint via HTTP and finally disconnects Alice to trigger an offline event.

  • Save test-client.js.

✔️ Awesome, I've got everything!

Great. Make sure you have saved test-client.js before running it.

ⓧ I'd like to double check the full code

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

const SERVER_1 = "http://localhost:3000";

async function getToken(server, userId, username) {
  const response = await fetch(`${server}/auth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const data = await response.json();
  return data.token;
}

function connectUser(server, token, label) {
  const socket = io(server, {
    auth: { token },
    transports: ["websocket"],
  });

  socket.on("connect", () => {
    console.log(`[${label}] Connected to ${server} | Socket: ${socket.id}`);
  });

  socket.on("connect_error", (err) => {
    console.log(`[${label}] Connection error: ${err.message}`);
  });

  socket.on("user:online", (data) => {
    console.log(`[${label}] User came online: ${data.username}`);
  });

  socket.on("user:offline", (data) => {
    console.log(`[${label}] User went offline: ${data.username}`);
  });

  socket.on("notification:receive", (data) => {
    console.log(`[${label}] Notification from ${data.from.username}: ${data.title} - ${data.body}`);
  });

  return socket;
}

async function main() {
  console.log("--- Presence & Notification Test ---\n");

  const tokenAlice = await getToken(SERVER_1, "user-1", "Alice");
  const tokenBob = await getToken(SERVER_1, "user-2", "Bob");
  const tokenCharlie = await getToken(SERVER_1, "user-3", "Charlie");

  const alice = connectUser(SERVER_1, tokenAlice, "Alice");
  const bob = connectUser(SERVER_1, tokenBob, "Bob");
  const charlie = connectUser(SERVER_1, tokenCharlie, "Charlie");

  await new Promise((resolve) => setTimeout(resolve, 2000));

  alice.emit("notification:send", {
    targetUserId: "user-3",
    title: "Hey Charlie",
    body: "Direct message from Alice!",
  });

  await new Promise((resolve) => setTimeout(resolve, 1000));

  bob.emit("notification:broadcast", {
    title: "System Alert",
    body: "Scheduled maintenance in 30 minutes",
  });

  await new Promise((resolve) => setTimeout(resolve, 1000));

  const presenceRes = await fetch(`${SERVER_1}/presence`);
  const presence = await presenceRes.json();
  console.log("Online users:", JSON.stringify(presence, null, 2));

  alice.disconnect();
  await new Promise((resolve) => setTimeout(resolve, 1000));

  bob.disconnect();
  charlie.disconnect();
  process.exit(0);
}

main().catch(console.error);
  • Start the server by running:
node server.js
  • Open a second terminal in the same project folder, then run the test client:
node test-client.js
  • You should see output showing all three users connecting, presence events firing, Alice's targeted notification reaching only Charlie, Bob's broadcast reaching everyone, the presence HTTP response listing all three users, and Alice's offline event after she disconnects.
  • Stop the server with Ctrl+C in the first terminal.

Not seeing notification or presence output?

If Charlie does not log a notification from Alice, check that the notification:send handler emits to user:${targetUserId} (with backticks), not a plain string.

If presence events are missing entirely, confirm that the user:online and user:offline listeners in test-client.js match the event names the server emits (they are case-sensitive).

Help me debug missing notifications in my Socket.IO presence system

Your notification system now handles presence tracking and message routing for a single server instance. Next, you will wire in Redis so these features work across multiple server processes.

Scale Horizontally with Redis

Your notification server works perfectly on a single process. But a single process has a ceiling. One Node.js instance handles around 10,000 to 50,000 concurrent WebSocket connections before memory and CPU bottleneck.

To go beyond that, you need multiple server instances sharing state. In this step, you will wire the Redis Pub/Sub adapter into Socket.IO so that events emitted on one instance reach clients connected to any other instance. You will also move the in-memory presence map into Redis so every instance reads from a single source of truth.

In this step, get ready to:
  • Wire the Redis adapter for cross-instance message routing.
  • Replace the in-memory presence map with Redis-backed hash storage.
  • Run a two-instance cluster and verify cross-instance notification delivery.
Wire the Redis adapter and replace in-memory presence

The @socket.io/redis-adapter package intercepts every io.to().emit() call and publishes it to a Redis channel. Other server instances subscribe to that channel and deliver the event to their local sockets. This is transparent to your application code.

  • In server.js, find the existing import block at the top of the file:
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { generateToken, authMiddleware } from "./auth.js";
  • Replace the entire import block with this updated version that adds the Redis and adapter imports:
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-adapter";
import { generateToken, authMiddleware } from "./auth.js";
  • Directly below the PORT line, add a REDIS_URL constant:
const PORT = process.env.PORT || 3000;
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";

What does REDIS_URL do?

This constant lets you override the Redis connection string via an environment variable in production. Locally, it defaults to the Docker container you already have running on port 6379.

  • Now find the line that creates the in-memory presence map:
// In-memory presence map
const presenceMap = new Map();
  • Delete those two lines entirely and replace them with the Redis adapter setup and the Redis-backed presence functions by pasting this code in the same location (between the io creation and the io.use(authMiddleware) line):
// Redis adapter setup
const pubClient = createClient({ url: REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createAdapter(pubClient, subClient));

console.log(`[Server:${PORT}] Redis adapter connected`);

// Redis-backed presence store
const PRESENCE_KEY = "presence:online";

async function addPresence(userId, username) {
  await pubClient.hSet(PRESENCE_KEY, userId, JSON.stringify({ username, connectedAt: Date.now() }));
}

async function removePresence(userId) {
  await pubClient.hDel(PRESENCE_KEY, userId);
}

async function getPresence() {
  const raw = await pubClient.hGetAll(PRESENCE_KEY);
  const result = {};
  for (const [userId, data] of Object.entries(raw)) {
    result[userId] = JSON.parse(data);
  }
  return result;
}

What does this code do?

The pubClient publishes Socket.IO events to Redis. The subClient (a duplicate connection) subscribes to those events. Together they let multiple server instances share every emit() call via Redis Pub/Sub.

The three presence functions replace the in-memory Map. hSet writes a user entry into a Redis hash. hDel removes it. hGetAll fetches every entry at once. Because Redis is shared between instances, all servers see the same presence data.

  • Next, update the connection handler. Find this line:
io.on("connection", (socket) => {
  • Change it to an async callback:
io.on("connection", async (socket) => {
  • Inside the connection handler, find the two lines that use presenceMap to add a user:
  presenceMap.set(userId, { username, connectedAt: Date.now() });
  io.to("presence").emit("user:online", { userId, username });
  • Replace them with the async Redis call:
  await addPresence(userId, username);
  io.to("presence").emit("user:online", { userId, username });
  • Find the disconnect handler:
  socket.on("disconnect", (reason) => {
    console.log(`[Server:${PORT}] User disconnected: ${username} (${reason})`);
    presenceMap.delete(userId);
    io.to("presence").emit("user:offline", { userId, username });
  });
  • Replace it with an async version using removePresence:
  socket.on("disconnect", async (reason) => {
    console.log(`[Server:${PORT}] User disconnected: ${username} (${reason})`);
    await removePresence(userId);
    io.to("presence").emit("user:offline", { userId, username });
  });
  • Finally, find the /presence endpoint:
app.get("/presence", (req, res) => {
  const online = {};
  for (const [userId, data] of presenceMap.entries()) {
    online[userId] = data;
  }
  res.json({ online });
});
  • Replace it with the Redis-backed version:
app.get("/presence", async (req, res) => {
  const online = await getPresence();
  res.json({ online });
});
  • Save server.js.

Why Redis Pub/Sub instead of a shared database?

A traditional database would add latency on every message (query, write, poll). Redis Pub/Sub delivers messages to subscribers in sub-millisecond time because it holds everything in memory. This is the same approach used by Slack, Discord, and enterprise messaging platforms to fan out events across server clusters.

Seeing a connection error on startup?

Make sure your Redis container is still running. Run docker compose ps and confirm the redis container shows a healthy status. If it stopped, run docker compose up -d to restart it.

Also check that the await Promise.all([pubClient.connect(), subClient.connect()]) line is at the top level of your module (not inside a function). ES module top-level await only works at the module scope.

help me debug a Redis connection error in my Socket.IO server

Create the cluster launcher

To prove horizontal scaling works, you need two server instances running simultaneously. A simple launcher script spawns both processes with different PORT values.

  • Create a new file called start-cluster.js in your project folder and paste the following code:
import { spawn } from "node:child_process";

const instances = [
  { port: 3000 },
  { port: 3001 },
];

const processes = [];

for (const instance of instances) {
  const child = spawn("node", ["server.js"], {
    env: { ...process.env, PORT: String(instance.port) },
    stdio: "inherit",
  });

  child.on("exit", (code) => {
    console.log(`[Cluster] Instance on port ${instance.port} exited with code ${code}`);
  });

  processes.push(child);
}

process.on("SIGINT", () => {
  console.log("[Cluster] Shutting down all instances...");
  processes.forEach((p) => p.kill("SIGTERM"));
  setTimeout(() => process.exit(0), 12000);
});

console.log(`[Cluster] Started ${instances.length} instances on ports: ${instances.map((i) => i.port).join(", ")}`);

What does this code do?

The script uses Node.js child_process.spawn to launch server.js twice with different PORT environment variables. Each child process is a fully independent server instance. The stdio: "inherit" option pipes each instance's logs directly to your terminal.

When you press Ctrl+C, the SIGINT handler sends SIGTERM to both children and waits 12 seconds for them to shut down cleanly before exiting.

  • Save start-cluster.js.
  • Stop any running server instance (press Ctrl+C if one is active).
  • Start the cluster by running:
npm run start:cluster

You should see two startup messages confirming both instances connected to Redis and are listening on ports 3000 and 3001.

Seeing EADDRINUSE on port 3000?

A previous server process is still running on that port. Find it and kill it. On macOS or Linux, run lsof -i :3000 to find the process ID, then kill <PID>. On Windows, use netstat -ano | findstr :3000 and then taskkill /PID <PID> /F.

help me fix an EADDRINUSE error when starting my Socket.IO cluster

Test cross-instance notification delivery

The real test of horizontal scaling is sending a message from a user on instance 1 to a user on instance 2. If Redis is wired correctly, the message crosses the boundary invisibly.

  • Open test-client.js and replace its entire contents with the updated test harness:
import { io } from "socket.io-client";

const SERVER_1 = "http://localhost:3000";
const SERVER_2 = "http://localhost:3001";

async function getToken(server, userId, username) {
  const response = await fetch(`${server}/auth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const data = await response.json();
  return data.token;
}

function connectUser(server, token, label) {
  const socket = io(server, {
    auth: { token },
    transports: ["websocket"],
  });

  socket.on("connect", () => {
    console.log(`[${label}] Connected to ${server} | Socket: ${socket.id}`);
  });

  socket.on("connect_error", (err) => {
    console.log(`[${label}] Connection error: ${err.message}`);
  });

  socket.on("user:online", (data) => {
    console.log(`[${label}] User came online: ${data.username}`);
  });

  socket.on("user:offline", (data) => {
    console.log(`[${label}] User went offline: ${data.username}`);
  });

  socket.on("notification:receive", (data) => {
    console.log(`[${label}] Notification from ${data.from.username}: ${data.title} - ${data.body}`);
  });

  return socket;
}

async function main() {
  console.log("--- Production WebSocket Test Harness ---\n");

  // Get tokens
  const tokenAlice = await getToken(SERVER_1, "user-1", "Alice");
  const tokenBob = await getToken(SERVER_1, "user-2", "Bob");
  const tokenCharlie = await getToken(SERVER_2, "user-3", "Charlie");

  // Connect users to different instances
  const alice = connectUser(SERVER_1, tokenAlice, "Alice");
  const bob = connectUser(SERVER_1, tokenBob, "Bob");
  const charlie = connectUser(SERVER_2, tokenCharlie, "Charlie");

  // Wait for connections
  await new Promise((resolve) => setTimeout(resolve, 2000));

  console.log("\n--- Sending targeted notification (cross-instance) ---");
  // Alice (on server 1) sends to Charlie (on server 2)
  alice.emit("notification:send", {
    targetUserId: "user-3",
    title: "Cross-Instance Test",
    body: "This message crosses the Redis boundary!",
  });

  await new Promise((resolve) => setTimeout(resolve, 1000));

  console.log("\n--- Sending broadcast notification ---");
  bob.emit("notification:broadcast", {
    title: "System Alert",
    body: "Scheduled maintenance in 30 minutes",
  });

  await new Promise((resolve) => setTimeout(resolve, 1000));

  // Check presence via HTTP
  console.log("\n--- Checking presence (HTTP endpoint) ---");
  const presenceRes = await fetch(`${SERVER_1}/presence`);
  const presence = await presenceRes.json();
  console.log("Online users:", JSON.stringify(presence, null, 2));

  // Disconnect one user and observe
  console.log("\n--- Alice disconnecting ---");
  alice.disconnect();

  await new Promise((resolve) => setTimeout(resolve, 1000));

  // Cleanup
  bob.disconnect();
  charlie.disconnect();

  console.log("\n--- Test complete ---");
  process.exit(0);
}

main().catch(console.error);

What changed in the test harness?

The key difference is SERVER_2 pointing to port 3001. Charlie now gets a token from instance 2 and connects to instance 2. When Alice (on instance 1) sends a notification to Charlie's user:3 room, the Redis adapter forwards that event from instance 1 to instance 2 where Charlie is connected.

  • Save test-client.js.
  • Open a second terminal window and run the test:
npm test

You should see Charlie receive the cross-instance notification from Alice, confirming the Redis adapter is routing messages between instances.

What should I see?

Look for this line in the output:

[Charlie] Notification from Alice: Cross-Instance Test - This message crosses the Redis boundary!

This proves the message traveled from server 1 through Redis Pub/Sub to server 2 where Charlie was connected. The broadcast notification should also appear on all three users, regardless of which instance they sit on.

Charlie not receiving the notification?

Verify both server instances show "Redis adapter connected" in the cluster terminal. If only one does, the adapter failed to attach on the other instance.

Check that Charlie's token is being fetched from SERVER_2 (port 3001) in your test-client.js. Both servers share the same JWT secret, so tokens are interchangeable. The important thing is that Charlie's socket connects to port 3001.

help me debug cross-instance notification delivery with Socket.IO Redis adapter

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files.

ⓧ I'd like to double check the full code

Here is the full server.js after all changes in this step:

import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-adapter";
import { generateToken, authMiddleware } from "./auth.js";

const PORT = process.env.PORT || 3000;
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";

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

app.use(express.json());

const io = new Server(httpServer, {
  transports: ["websocket"],
  maxHttpBufferSize: 1e6,
  pingInterval: 25000,
  pingTimeout: 20000,
  cors: {
    origin: process.env.NODE_ENV === "production" ? false : ["http://localhost:3000"],
  },
});

// Redis adapter setup
const pubClient = createClient({ url: REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createAdapter(pubClient, subClient));

console.log(`[Server:${PORT}] Redis adapter connected`);

// Redis-backed presence store
const PRESENCE_KEY = "presence:online";

async function addPresence(userId, username) {
  await pubClient.hSet(PRESENCE_KEY, userId, JSON.stringify({ username, connectedAt: Date.now() }));
}

async function removePresence(userId) {
  await pubClient.hDel(PRESENCE_KEY, userId);
}

async function getPresence() {
  const raw = await pubClient.hGetAll(PRESENCE_KEY);
  const result = {};
  for (const [userId, data] of Object.entries(raw)) {
    result[userId] = JSON.parse(data);
  }
  return result;
}

// Authentication middleware
io.use(authMiddleware);

// Connection handler
io.on("connection", async (socket) => {
  const { userId, username } = socket.data;

  console.log(`[Server:${PORT}] User connected: ${username} (${userId}) | Socket: ${socket.id}`);

  // Join user-specific room and presence room
  socket.join(`user:${userId}`);
  socket.join("presence");

  // Add to presence store and broadcast
  await addPresence(userId, username);
  io.to("presence").emit("user:online", { userId, username });

  // Handle targeted notification
  socket.on("notification:send", (data) => {
    const { targetUserId, title, body } = data;
    io.to(`user:${targetUserId}`).emit("notification:receive", {
      from: { userId, username },
      title,
      body,
      timestamp: Date.now(),
    });
    console.log(`[Server:${PORT}] Notification: ${username} -> user:${targetUserId}`);
  });

  // Handle broadcast notification
  socket.on("notification:broadcast", (data) => {
    const { title, body } = data;
    io.to("presence").emit("notification:receive", {
      from: { userId, username },
      title,
      body,
      timestamp: Date.now(),
    });
    console.log(`[Server:${PORT}] Broadcast notification from ${username}`);
  });

  // Handle disconnect
  socket.on("disconnect", async (reason) => {
    console.log(`[Server:${PORT}] User disconnected: ${username} (${reason})`);
    await removePresence(userId);
    io.to("presence").emit("user:offline", { userId, username });
  });
});

// HTTP endpoints
app.post("/auth/token", (req, res) => {
  const { userId, username } = req.body;
  if (!userId || !username) {
    return res.status(400).json({ error: "userId and username are required" });
  }
  const token = generateToken(userId, username);
  res.json({ token });
});

app.get("/health", async (req, res) => {
  const sockets = await io.fetchSockets();
  res.json({
    status: "ok",
    port: PORT,
    connections: sockets.length,
    uptime: process.uptime(),
  });
});

app.get("/presence", async (req, res) => {
  const online = await getPresence();
  res.json({ online });
});

httpServer.listen(PORT, () => {
  console.log(`[Server:${PORT}] Real-time notification server running on http://localhost:${PORT}`);
});

Here is start-cluster.js:

import { spawn } from "node:child_process";

const instances = [
  { port: 3000 },
  { port: 3001 },
];

const processes = [];

for (const instance of instances) {
  const child = spawn("node", ["server.js"], {
    env: { ...process.env, PORT: String(instance.port) },
    stdio: "inherit",
  });

  child.on("exit", (code) => {
    console.log(`[Cluster] Instance on port ${instance.port} exited with code ${code}`);
  });

  processes.push(child);
}

process.on("SIGINT", () => {
  console.log("[Cluster] Shutting down all instances...");
  processes.forEach((p) => p.kill("SIGTERM"));
  setTimeout(() => process.exit(0), 12000);
});

console.log(`[Cluster] Started ${instances.length} instances on ports: ${instances.map((i) => i.port).join(", ")}`);

Here is the updated test-client.js:

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

const SERVER_1 = "http://localhost:3000";
const SERVER_2 = "http://localhost:3001";

async function getToken(server, userId, username) {
  const response = await fetch(`${server}/auth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const data = await response.json();
  return data.token;
}

function connectUser(server, token, label) {
  const socket = io(server, {
    auth: { token },
    transports: ["websocket"],
  });

  socket.on("connect", () => {
    console.log(`[${label}] Connected to ${server} | Socket: ${socket.id}`);
  });

  socket.on("connect_error", (err) => {
    console.log(`[${label}] Connection error: ${err.message}`);
  });

  socket.on("user:online", (data) => {
    console.log(`[${label}] User came online: ${data.username}`);
  });

  socket.on("user:offline", (data) => {
    console.log(`[${label}] User went offline: ${data.username}`);
  });

  socket.on("notification:receive", (data) => {
    console.log(`[${label}] Notification from ${data.from.username}: ${data.title} - ${data.body}`);
  });

  return socket;
}

async function main() {
  console.log("--- Production WebSocket Test Harness ---\n");

  // Get tokens
  const tokenAlice = await getToken(SERVER_1, "user-1", "Alice");
  const tokenBob = await getToken(SERVER_1, "user-2", "Bob");
  const tokenCharlie = await getToken(SERVER_2, "user-3", "Charlie");

  // Connect users to different instances
  const alice = connectUser(SERVER_1, tokenAlice, "Alice");
  const bob = connectUser(SERVER_1, tokenBob, "Bob");
  const charlie = connectUser(SERVER_2, tokenCharlie, "Charlie");

  // Wait for connections
  await new Promise((resolve) => setTimeout(resolve, 2000));

  console.log("\n--- Sending targeted notification (cross-instance) ---");
  // Alice (on server 1) sends to Charlie (on server 2)
  alice.emit("notification:send", {
    targetUserId: "user-3",
    title: "Cross-Instance Test",
    body: "This message crosses the Redis boundary!",
  });

  await new Promise((resolve) => setTimeout(resolve, 1000));

  console.log("\n--- Sending broadcast notification ---");
  bob.emit("notification:broadcast", {
    title: "System Alert",
    body: "Scheduled maintenance in 30 minutes",
  });

  await new Promise((resolve) => setTimeout(resolve, 1000));

  // Check presence via HTTP
  console.log("\n--- Checking presence (HTTP endpoint) ---");
  const presenceRes = await fetch(`${SERVER_1}/presence`);
  const presence = await presenceRes.json();
  console.log("Online users:", JSON.stringify(presence, null, 2));

  // Disconnect one user and observe
  console.log("\n--- Alice disconnecting ---");
  alice.disconnect();

  await new Promise((resolve) => setTimeout(resolve, 1000));

  // Cleanup
  bob.disconnect();
  charlie.disconnect();

  console.log("\n--- Test complete ---");
  process.exit(0);
}

main().catch(console.error);

Your notification system now scales horizontally. Any number of server instances can share events through Redis, and presence state is consistent across the cluster. Next up, you will implement graceful shutdown with connection draining for zero-downtime deploys.

Secret mission

Graceful Shutdown with Connection Draining

Your server works great, but what happens when you need to deploy a new version? In production, you can't just kill the process and drop thousands of connections. Implement graceful shutdown with connection draining so your system supports zero-downtime rolling deploys.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker, so there are no ongoing cloud costs.

Resources you used:

  • Redis Docker container (running via docker-compose.yml).
  • Project folder realtime-notification-system on your Desktop (includes node_modules, source files, and configuration).

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to extend it with new features soon.

  • Your Redis container will continue running in the background and use a small amount of memory.
  • Your project files remain on your Desktop ready to use.
  • To start the server again later, open a terminal in your project folder and run node start-cluster.js.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping all your files intact. You can resume any time.

  • Stop any running Node.js server processes by pressing Ctrl+C in each terminal window.
  • Stop the Redis container by running this command from your project folder:
docker compose stop
  • To resume later, run docker compose start from the project folder, then start your server.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Stop any running Node.js server processes by pressing Ctrl+C in each terminal window.
  • Remove the Redis container and its network by running this command from your project folder:
docker compose down
  • Delete the project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf realtime-notification-system
  • On Windows, run:
cd ~\Desktop
rmdir /s /q realtime-notification-system
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see realtime-notification-system listed.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade real-time notification and presence system that mirrors the architecture used by Slack, Discord, and enterprise messaging platforms.

You've learned how to:

  • Build a production-hardened Socket.IO server with JWT authentication that rejects unauthenticated clients before the WebSocket upgrade completes.
  • Implement real-time presence tracking and room-based notification routing that delivers targeted messages to specific users.
  • Wire horizontal scaling across multiple server instances using the Redis Pub/Sub adapter and verify cross-instance message delivery with a multi-user test harness.
  • Secret Mission: Implement graceful shutdown with connection draining for zero-downtime rolling deploys.

Ready to quiz yourself?