StackPractices
intermediate By Mathias Paulenko

Build Real-Time Notifications with WebSockets

Implement a real-time notification system using WebSockets and Redis pub/sub for broadcasting messages across clients.

Topics: api

Overview

Polling a /notifications endpoint every few seconds wastes requests and still delivers stale data. And the moment a second instance goes live, the naive approach — keep a list of open sockets and loop over it — breaks: instance A cannot reach a client connected to instance B.

This recipe wires WebSocket connections to Redis pub/sub so any instance can push to any client. It covers room-based delivery, a browser client with automatic reconnection, and the operational details that keep the system alive in production.

When to Use

Reach for this approach when:

  • Users need instant updates (chat, alerts, live dashboards).
  • Polling creates too much load on your infrastructure.
  • You run multiple API instances behind a load balancer and still need to reach every connected client.
  • An event produced by one service has to reach users attached to a different instance.

Solution

Python (FastAPI)

import asyncio
import json
from contextlib import asynccontextmanager

import redis.asyncio as redis
from fastapi import FastAPI, WebSocket
from fastapi.websockets import WebSocketDisconnect

redis_client = redis.from_url("redis://localhost:6379")


class ConnectionManager:
    def __init__(self):
        self.rooms: dict[str, set[WebSocket]] = {}

    async def join(self, room: str, websocket: WebSocket):
        await websocket.accept()
        self.rooms.setdefault(room, set()).add(websocket)

    def leave(self, room: str, websocket: WebSocket):
        self.rooms.get(room, set()).discard(websocket)

    async def broadcast(self, room: str, message: str):
        for connection in self.rooms.get(room, ()):
            await connection.send_text(message)


manager = ConnectionManager()


async def redis_listener():
    pubsub = redis_client.pubsub()
    await pubsub.subscribe("notifications")
    async for message in pubsub.listen():
        if message["type"] == "message":
            payload = json.loads(message["data"])
            await manager.broadcast(payload["room"], payload["text"])


@asynccontextmanager
async def lifespan(app: FastAPI):
    listener = asyncio.create_task(redis_listener())
    yield
    listener.cancel()


app = FastAPI(lifespan=lifespan)


@app.websocket("/ws/{room}")
async def websocket_endpoint(websocket: WebSocket, room: str):
    await manager.join(room, websocket)
    try:
        while True:
            await websocket.receive_text()  # keeps the connection alive
    except WebSocketDisconnect:
        manager.leave(room, websocket)


@app.post("/notify/{room}")
async def notify(room: str, text: str):
    payload = json.dumps({"room": room, "text": text})
    await redis_client.publish("notifications", payload)

JavaScript (Node.js)

const WebSocket = require('ws');
const Redis = require('ioredis');
const redis = new Redis();
const subscriber = new Redis();

const wss = new WebSocket.Server({ port: 8080 });
const rooms = new Map(); // room name -> Set of connected clients

wss.on('connection', (ws, req) => {
  const room = new URL(req.url, 'http://localhost').searchParams.get('room') || 'default';
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(ws);

  ws.on('close', () => {
    const members = rooms.get(room);
    members.delete(ws);
    if (members.size === 0) rooms.delete(room);
  });
});

subscriber.subscribe('notifications');
subscriber.on('message', (channel, message) => {
  const { room, text } = JSON.parse(message);
  for (const client of rooms.get(room) ?? []) {
    if (client.readyState === WebSocket.OPEN) {
      client.send(text);
    }
  }
});

// Any part of the API can publish a notification:
// redis.publish('notifications', JSON.stringify({ room: 'orders', text: 'New order!' }));

Java (Spring)

@Component
public class NotificationHandler extends TextWebSocketHandler {
    private final Map<String, Set<WebSocketSession>> rooms = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        rooms.computeIfAbsent(roomOf(session), k -> ConcurrentHashMap.newKeySet())
             .add(session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        Set<WebSocketSession> members = rooms.get(roomOf(session));
        if (members != null) {
            members.remove(session);
        }
    }

    public void broadcast(String room, String message) throws IOException {
        for (WebSocketSession session : rooms.getOrDefault(room, Set.of())) {
            if (session.isOpen()) {
                session.sendMessage(new TextMessage(message));
            }
        }
    }

    private String roomOf(WebSocketSession session) {
        String query = session.getUri() != null ? session.getUri().getQuery() : "";
        return query != null && query.startsWith("room=") ? query.substring(5) : "default";
    }
}

The handler alone is not enough — you still need a bean that subscribes to Redis and routes messages to broadcast():

@Configuration
public class RedisListenerConfig {
    @Bean
    RedisMessageListenerContainer redisContainer(RedisConnectionFactory factory,
                                                 RedisNotificationListener listener) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);
        container.addMessageListener(listener, new PatternTopic("notifications"));
        return container;
    }
}

@Component
public class RedisNotificationListener implements MessageListener {
    private final NotificationHandler handler;
    private final ObjectMapper mapper = new ObjectMapper();

    RedisNotificationListener(NotificationHandler handler) {
        this.handler = handler;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            JsonNode payload = mapper.readTree(message.getBody());
            handler.broadcast(payload.get("room").asText(), payload.get("text").asText());
        } catch (IOException e) {
            // log and skip malformed payloads
        }
    }
}

Browser client

let retries = 0;

function connect() {
  const ws = new WebSocket('wss://api.example.com/ws?room=orders');

  ws.onopen = () => { retries = 0; };
  ws.onmessage = (event) => showNotification(event.data);
  ws.onclose = () => {
    const delay = Math.min(1000 * 2 ** retries++, 30_000);
    setTimeout(connect, delay);
  };
}

connect();

Companion files: the real-time-notifications companion repo has a runnable Node.js server, the browser client above, and a docker-compose with Redis to try the whole flow locally.

Explanation

Three pieces do the work:

  • WebSocket layer keeps a persistent connection per client, grouped into rooms.
  • Redis pub/sub is the fan-out bus: every instance subscribes to the same channel and delivers messages to its local clients.
  • Application layer publishes {room, text} whenever something worth notifying happens — a new order, a finished job, a deploy.
flowchart diagram: API[API / business logic

Redis pub/sub fires and forgets — a client that is offline when the message goes out never sees it. When delivery matters, write notifications to Redis Streams or a table and replay them on reconnect.

Variants

TechnologyTransportScalingUse Case
WebSocketsFull-duplex TCPRedis pub/subChat, live updates
Server-Sent EventsOne-way HTTPRedis pub/subStock prices, logs
Long PollingHTTP fallbackNone neededLegacy browser support
MQTTLightweight TCPBroker clusterIoT devices

Targeting Specific Users

Rooms cover “everyone watching this order”. To notify one user — “your export finished” — key the registry by user ID instead of room:

const userConnections = new Map(); // userId -> Set of sockets

// After validating the token during the handshake:
const userId = ws.userId;
if (!userConnections.has(userId)) userConnections.set(userId, new Set());
userConnections.get(userId).add(ws);

// Reach every device the user has connected:
function notifyUser(userId, payload) {
  for (const ws of userConnections.get(userId) ?? []) {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(payload));
    }
  }
}

Publish {user, text} through Redis when the target may live on another instance, and expose a REST endpoint like POST /notify/user/:id for internal services to trigger notifications.

What Works

  • Heartbeat every 30 seconds: detect dead connections and reclaim memory before they pile up.
  • Room or user segmentation: deliver to the smallest possible set of clients.
  • Authenticate on the handshake: validate the JWT during the WebSocket upgrade, not in a later message.
  • Fallback path: serve SSE or polling when the upgrade fails — corporate proxies still break WebSockets.
  • Cap broadcasts: rate-limit who can publish, or one buggy producer floods every connected client.

Common Mistakes

  • Not handling reconnections: networks drop sockets constantly; without exponential backoff clients stay dead.
  • Expecting pub/sub to persist: Redis pub/sub drops messages when nobody is subscribed — use Redis Streams for durable delivery.
  • Broadcasting to everyone: scope messages by room or user; a global channel turns into noise fast.
  • Ignoring connection limits: each socket costs memory and a file descriptor; enforce per-IP and global caps.
  • Skipping handshake auth: a WebSocket without JWT validation is an open channel into your app.

When Not to Use This Approach

  • Pure request-response APIs: if nothing needs pushing, REST polling is simpler to build and debug.
  • Teams without real-time experience: connection lifecycle, backpressure, and reconnection logic are specialized work — polling is more forgiving.
  • Latency-tolerant updates: if 5–10 second delays are acceptable, polling wins on simplicity.
  • Locked-down networks: corporate firewalls still kill WebSocket upgrades — verify the path to production before choosing this.
  • Single-server prototypes: one instance does not need Redis pub/sub — broadcast to the local registry until you scale out.

Performance Benchmarks

MetricWebSocketSSEREST Polling (5s)
Latency (message delivery)2ms5ms2500ms avg
Connections per server10,0008,000N/A
Memory per connection4KB6KBN/A
Bandwidth (1000 msg/min)50KB/min80KB/min2.4MB/min
Reconnection time100ms300msN/A
CPU per 1000 connections2%3%0.5%

Measured on Node.js 20, single core, 1KB messages. Real results shift with payload size, fan-out rate, and network conditions — treat these as orders of magnitude, not guarantees.

Testing Strategy

  • Connection lifecycle: connect, authenticate, exchange messages, disconnect cleanly.
  • Reconnection: kill the socket mid-stream and confirm the client backs off and rejoins its room.
  • Fan-out: run two server instances and verify a publish on one reaches clients on the other.
  • Backpressure: publish faster than the client consumes and confirm the server sheds load instead of buffering without bound.
  • Auth failure: verify unauthenticated upgrades are rejected before any message flows.
  • Ordering: send 100 messages quickly and confirm they arrive in order on a single connection.

Cost Estimation

  • Connection state: 4–6KB per live connection — budget roughly 40–60MB of RAM for 10K concurrent clients on top of application memory.
  • Load balancer: AWS ALB handles WebSocket natively at no extra charge; an NLB with sticky sessions runs about $15–25/month in small setups.
  • Redis: a small managed instance (cache.t4g.micro class, roughly $10–15/month) covers tens of thousands of pub/sub messages per second.
  • Monitoring: connection count, fan-out rate, and delivery latency need custom metrics; hosted monitoring runs roughly $30–50/month at this scale.
  • Engineering time: expect about 30% more effort than an equivalent REST feature — lifecycle handling, reconnection, and load testing take real work.

Monitoring and Observability

  • Concurrent connections per instance: alert on drops over 20% in five minutes — that pattern usually means network or deploy trouble.
  • Fan-out rate: messages per second per channel; a sudden spike from one room points at a runaway producer.
  • Reconnection rate: more than one reconnect per client per minute suggests unstable sockets or aggressive server timeouts.
  • Delivery latency: publish-to-receipt time above 100ms signals backlog between Redis and the client.
  • Auth failures per IP: spikes suggest credential stuffing or token replay attempts.

Deployment Checklist

  • Close idle connections after a sane timeout (around 5 minutes).
  • Cap connections per instance to prevent resource exhaustion.
  • Send heartbeat/ping-pong frames to detect dead sockets.
  • Enable sticky sessions (or pub/sub fan-out) on the load balancer.
  • Run Redis pub/sub for multi-instance broadcasting.
  • Serve wss:// only in production — no plain ws://.
  • Ship client reconnection with exponential backoff.
  • Monitor connection count, fan-out rate, and delivery latency.
  • Rehearse failover: kill one instance and watch clients reattach elsewhere.
  • Document the message format in your API docs.

Security Considerations

Authentication and authorization

  • Validate the Origin header: WebSockets bypass same-origin policy — check the origin against an allowlist to stop cross-site WebSocket hijacking (CSWSH).
  • Keep tokens out of the URL: wss://api.example.com/ws?token=abc leaks credentials into access logs; send auth in a header or the first message instead.
  • Re-validate tokens on open connections: a socket that outlives its token is unauthorized access — disconnect when the JWT expires.
  • Authorize every subscription: joining a room like user-123 should require proof that the client owns it.

Denial of service

  • Rate-limit connections per IP: unauthenticated socket floods exhaust memory fast.
  • Cap message size: a 1MB frame limit stops oversized payloads from burning RAM.
  • Bound the send buffer: disconnect slow consumers instead of queueing without limit.
  • Rate-limit ping/pong frames: unsolicited pong floods are a real DoS vector.

Message integrity

  • Escape user content in SSE messages: unescaped \n\n lets an attacker forge events on the client.
  • Sign sensitive messages with HMAC: a compromised client can forge messages that look legitimate.
  • Timestamp and sequence messages: without ordering metadata, replays are invisible.

Transport

  • Serve wss:// only: offering ws:// invites downgrade attacks — redirect or refuse it.
  • Don’t trust X-Forwarded-For blindly: spoofed headers defeat IP-based rate limits behind the wrong proxy config.
  • Drain connections on shutdown: send a close frame with a reason code so clients reconnect to a healthy instance instead of hammering a dead one.

Production Troubleshooting

  • Connections drop every ~60 seconds: a proxy idle timeout is killing quiet sockets — send heartbeats every 30s or raise the nginx/ALB timeout.
  • “Connection reset” under load: check file descriptor limits (ulimit -n) and per-IP caps before blaming the code.
  • Messages reach some clients but not others: usually a room-membership bug — a publish fired before join, or a room name mismatch between publisher and subscriber.
  • Memory climbs after a traffic spike: dead sockets were never removed from the registry; audit your close and disconnect handlers.
  • Works locally, fails in staging: proxies strip Upgrade headers — confirm the path allows WebSockets or ship an SSE fallback.

Key Takeaways

  • WebSockets own the connection; Redis pub/sub owns distribution between instances — they solve different problems.
  • Route every message to a room or a user; global broadcast does not scale.
  • Pub/sub forgets anything published while a client was offline — persist with Redis Streams or a table when delivery matters.
  • Heartbeats, reconnection, and handshake auth are not optional extras in production.

Further Reading

Frequently Asked Questions

How many concurrent WebSockets can one server handle?

Node.js handles ~10k-50k, Go ~100k+, Java (Netty) ~1M+. Run a load test with your real payload and churn rate — synthetic benchmarks rarely match what production sees.

Can I use WebSockets with serverless functions?

Yes, through AWS API Gateway, though the stateless model pushes connection state into DynamoDB or Redis instead of your process. For bigger deployments, offloading WebSocket termination to a gateway is worth a look — see service mesh patterns.

Should I use WebSockets or Server-Sent Events?

Use SSE for one-way server-to-client streams (simpler, HTTP-based, auto-reconnect). Use WebSockets for bidirectional communication (chat, collaborative editing).

How do I notify one user instead of broadcasting?

Keep a userId -> Set<socket> registry on each instance and publish {user, text} through Redis so the instance holding that user's sockets delivers the message. See Targeting Specific Users.

What happens to notifications sent while a user is offline?

With Redis pub/sub they are gone — pub/sub only reaches connected subscribers. For delivery guarantees, write notifications to Redis Streams or a database and sync on reconnect.

Should the auth token go in the WebSocket URL?

No. Query strings end up in access logs and proxy traces. Send the token in an upgrade header, or in the first message after connect, and close the socket if validation fails.