Skip to content
Blog

Building a Chat App with WebSockets and Redis Pub/Sub

Build a real-time chat app with Node.js, WebSockets, and Redis Pub/Sub — handling multiple server instances, message history, presence, and horizontal scaling.

Published on August 14, 2026

AI Assistant

Your chat server works in local testing. Then you deploy two replicas behind a load balancer, and suddenly messages only reach half the users: the person connected to server A can’t see what the person on server B sent. A WebSocket connection is pinned to one process, so a single server can never broadcast to everyone.

In this tutorial, you will learn how to build a horizontally scalable chat application using:

  • WebSockets (ws package) for persistent, two-way client-server communication.
  • Redis Pub/Sub (PUBLISH/SUBSCRIBE) to fan out messages across every server instance, not just the one that received them.
  • Redis data structures for message history (streams) and online presence (sets with TTLs).

By the end you’ll have a complete, runnable multi-server chat backend with history, presence, and a browser client — plus the operational considerations (backpressure, reconnection) that separate a demo from a production system.

Prerequisites

  • Node.js 18+ and npm.
  • Redis 5+ running locally (or a free Redis Cloud / Upstash instance). Redis is used for pub/sub, history, and presence.
  • Basic JavaScript and familiarity with async/await.

Install the two dependencies:

npm init -y
npm install ws redis

How WebSockets work

The WebSocket API opens “a two-way interactive communication session between the user’s browser and a server” — no polling required. Unlike a normal HTTP request, the connection starts with an HTTP handshake: the client sends Sec-WebSocket-Key, and the server responds with 101 Switching Protocols and Sec-WebSocket-Accept computed from that key. After the handshake, both sides can push frames over the same socket.

In Node.js, the ws package hides all of that. The simplest server:

// echo-server.js
const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws, req) => {
  console.log('client connected');
  ws.on('message', (data) => {
    // data is a Buffer by default; echo it back as text
    ws.send(`you said: ${data.toString()}`);
  });
  ws.send('welcome');
});

And the browser side:

const ws = new WebSocket('ws://localhost:8080');

ws.addEventListener('open', () => ws.send('hello'));
ws.addEventListener('message', (event) => {
  console.log('received:', event.data);
});

This works perfectly for one process. The moment you run two copies of echo-server.js, each client’s messages only reach the process they’re connected to. Redis Pub/Sub solves exactly that.

Redis Pub/Sub in one minute

Redis Pub/Sub is a fire-and-forget messaging system: a publisher calls PUBLISH channel payload, and Redis fans the message out to every client currently subscribed to that channel, in publish order. Crucially, it’s a separate, shared infrastructure all your server instances can reach.

$ redis-cli
> SUBSCRIBE chat:room:1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "chat:room:1"
3) (integer) 1

In a second terminal:

$ redis-cli
> PUBLISH chat:room:1 '{"user":"alice","text":"hi"}'
(integer) 1     # one subscriber received it

A few documented behaviors matter for chat:

  • PUBLISH returns the number of clients that received the message. If it returns 0, nobody was listening — for chat, that’s fine: the history store is the source of truth, not the live fan-out.
  • A subscribed connection enters a subscribe-only state: it may only issue SUBSCRIBE, UNSUBSCRIBE, PING, RESET, and QUIT. So your subscriber uses a dedicated Redis connection; your regular commands (GET, SET, XADD) use another.
  • Delivery is at-most-once: a subscriber that is offline when a message is published “misses it for good.” That’s why we store history separately and use streams for catch-up — pub/sub is the live broadcast layer, not durable storage.

Architecture: subscribe, publish, broadcast

The pattern for multi-server chat is:

  1. Every server instance opens a dedicated Redis subscriber connection and SUBSCRIBEs to the room’s channel.
  2. When a client sends a message, the server appends it to history (stream) and PUBLISHes it to the room channel.
  3. Every instance’s subscriber callback receives the published message and broadcasts it to its own locally-connected clients — including the sender’s instance.

Since each instance relays only to its own sockets, one publish reaches users on all instances. No instance ever talks to another directly.

Browser A ──ws──> Instance 1 ──PUBLISH──> Redis <──SUBSCRIBE── Instance 2 ──ws──> Browser B

Step 1: The chat server

Here is the complete server, server.js. It handles WebSocket connections, publishes messages, subscribes to the room channel, stores history in a stream, and tracks presence.

// server.js
const { WebSocketServer } = require('ws');
const http = require('node:http');
const { createClient } = require('redis');

const PORT = process.env.PORT || 8080;
const ROOM = 'chat:room:1';

// Two separate Redis connections: one for subscribing (blocked in pub/sub state),
// one for everything else (commands like XADD, SADD, SCARD).
const subscriber = createClient();
const client = createClient();

subscriber.on('error', (e) => console.error('subscriber error', e));
client.on('error', (e) => console.error('client error', e));

async function main() {
  await Promise.all([subscriber.connect(), client.connect()]);

  // 1. Subscribe to the room channel and broadcast to local sockets
  await subscriber.subscribe(ROOM, (message) => {
    broadcast(JSON.parse(message));
  });

  // 2. HTTP server for history + static client
  const server = http.createServer((req, res) => {
    if (req.url === '/history') return sendHistory(res);
    if (req.url === '/online') return sendPresence(res);
    res.writeHead(404).end();
  });

  // 3. WebSocket server layered on top of the same HTTP server
  const wss = new WebSocketServer({ server });

  wss.on('connection', (ws, req) => {
    const username = new URL(req.url, 'http://localhost').searchParams.get('user') || `anon-${Math.random().toString(36).slice(2, 7)}`;
    ws.username = username;
    markOnline(username);

    ws.on('message', async (data) => {
      const { text, room = ROOM } = JSON.parse(data.toString());
      const message = { user: username, text, ts: Date.now() };

      // Durable history first...
      await appendHistory(room, message);
      // ...then live fan-out through Redis (this triggers subscriber callbacks on EVERY instance)
      await client.publish(room, JSON.stringify(message));
    });

    ws.on('close', () => {
      markOffline(username);
      console.log(`${username} disconnected`);
    });
  });

  server.listen(PORT, () => console.log(`chat server on :${PORT}`));
}

// Broadcast to every client connected to THIS instance only
function broadcast(message) {
  for (const ws of wss.clients) {
    if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
  }
}

function markOnline(user) {
  client.sAdd(`online:${ROOM}`, user);          // add to room set
  client.expire(`online:${ROOM}`, 30);           // keep set alive
  client.set(`user:${user}`, 'online', { EX: 30 }); // per-user TTL
}

function markOffline(user) {
  client.sRem(`online:${ROOM}`, user);
}

async function sendHistory(res) {
  // Read up to the last 50 entries from the stream, oldest first
  const entries = await client.xRange(ROOM, '-', '+', { COUNT: 50 });
  const history = entries.map((e) => ({ ...e.message, id: e.id }));
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify(history));
}

async function sendPresence(res) {
  const online = await client.sMembers(`online:${ROOM}`);
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify({ count: online.length, users: online }));
}

async function appendHistory(room, message) {
  // XADD appends a field-value entry; MAXLEN ~ 1000 caps memory by evicting old entries
  await client.xAdd(room, '*', message, {
    TRIM: { strategy: 'MAXLEN', strategyModifier: '~', threshold: 1000 },
  });
}

main();

Let’s walk through the important parts:

  • Two Redis clients. subscriber is permanently inside pub/sub subscribe mode (no other commands allowed), while client runs the commands. This split is mandatory — Redis rejects GET/XADD on a subscribed connection.
  • subscriber.subscribe(ROOM, callback) registers the callback for every published message. The callback runs on every instance, which is the whole point: one publish, all instances broadcast locally.
  • wss.clients is the set of sockets on this instance; ws.readyState === ws.OPEN skips sockets that are closing.
  • Presence via SADD + EXPIRE. Online users live in a Redis Set. The TTL on the set and per-user key means a client that vanishes without a clean close (network drop, laptop closed) is automatically removed after 30 seconds.

Step 2: The browser client

Save this as client.html and open it in two tabs (pass different ?user= values) to see cross-instance delivery in action:

<!doctype html>
<html>
<body>
  <ul id="log"></ul>
  <input id="msg" placeholder="message" />
  <button onclick="send()">Send</button>
  <script>
    const user = new URLSearchParams(location.search).get('user') || 'anon';
    const ws = new WebSocket(`ws://localhost:8080/?user=${user}`);

    ws.addEventListener('message', (event) => {
      const { user: from, text } = JSON.parse(event.data);
      const li = document.createElement('li');
      li.textContent = `${from}: ${text}`;
      document.getElementById('log').appendChild(li);
    });

    ws.addEventListener('close', () => console.log('disconnected'));

    function send() {
      const input = document.getElementById('msg');
      ws.send(JSON.stringify({ text: input.value }));
      input.value = '';
    }
  </script>
</body>
</html>

Because the client simply echoes whatever JSON arrives over ws.onmessage, it works identically whether the message originated from its own instance or was relayed from another one through Redis.

Testing multi-instance delivery

Run two copies of the server on different ports, then connect clients to each:

PORT=8080 node server.js   # instance 1
PORT=8081 node server.js   # instance 2
$ curl -s "localhost:8080/history" && echo
[]

$ redis-cli PUBLISH chat:room:1 '{"user":"tester","text":"hello from redis-cli"}'
(integer) 2        # both instances received it

Then type in the tab connected to :8080, and the tab connected to :8081 sees the message. The tester publish above also appears in both — proof that your two instances are now one logical chat.

Step 3: Message history with Redis Streams

History is stored with Streams (XADD/XRANGE) rather than a plain list because streams give you ordered, ID-addressable entries with built-in trimming — exactly what a chat transcript needs:

$ redis-cli
> XRANGE chat:room:1 - + COUNT 2
1) 1) "1755139000000-0"
   2) 1) "user"
      2) "alice"
      3) "text"
      4) "hi"

The MAXLEN ~ 1000 trim keeps memory bounded while retaining the last 1000 messages, so the /history endpoint always returns something useful to new joiners. (If you need harder guarantees — per-message acknowledgement, exactly-once consumer semantics — Streams with consumer groups via XREADGROUP are the documented upgrade path; for a chat transcript, capped XADD/XRANGE is the right size of tool.)

Production considerations

  • Backpressure. MDN notes the WebSocket interface “doesn’t support backpressure”: if a slow client can’t keep up, messages buffer in memory. Mitigate by checking ws.bufferedAmount before sending and disconnecting clients that fall too far behind, or use WebSocketStream / a streaming-aware layer where backpressure is automatic.
  • Reconnection. The browser should reconnect with exponential backoff, and the server should tolerate join storms. On close, remember presence TTLs do the cleanup for you — no need to synchronize a “left” event across instances.
  • Message size and rates. PUBLISH payloads should stay small (a few KB); for blobs, upload to object storage and publish a reference. Guard against rate abuse, since pub/sub is at-most-once and cheap to flood.
  • Cluster caveat. In Redis Cluster, PUBLISH replies only count subscribers on the publishing node, but messages are still forwarded to every node — pub/sub keeps working across shards.

Putting It All Together

To recap the full flow, run everything and watch the transcript:

redis-server &                       # 1. start Redis
PORT=8080 node server.js &           # 2. start two instances
PORT=8081 node server.js &
open "client.html?user=alice"        # 3. open two browser tabs
open "client.html?user=bob"

Expected behavior:

# alice's tab (connected to :8080) sends "hi bob"
# bob's tab (connected to :8081) shows:  alice: hi bob
# bob sends "yo alice", alice sees it too
$ curl -s localhost:8080/history | jq length
2
$ curl -s localhost:8080/online
{"count":2,"users":["bob","alice"]}

History and presence are shared and consistent across both instances; the live broadcast reaches every user regardless of which instance holds their socket.

Conclusion & Next Steps

You’ve built a chat backend that scales horizontally: WebSockets handle the persistent connection, Redis Pub/Sub broadcasts each message to every server instance, streams persist history with bounded memory, and sets-with-TTLs provide presence without any distributed coordination. Add one more instance and it joins the room automatically — no reconfiguration.

Next steps: add per-room channels with PSUBSCRIBE on a chat:room:* pattern, secure the WebSocket handshake (Sec-WebSocket-Protocol subprotocol negotiation plus auth on the upgrade request), and move history to consumer groups if you ever need fan-out to multiple worker pipelines.

References / Sources