Build Real-Time Notifications with WebSockets
Implement a real-time notification system using WebSockets and Redis pub/sub for broadcasting messages across clients.
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.
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
| Technology | Transport | Scaling | Use Case |
|---|---|---|---|
| WebSockets | Full-duplex TCP | Redis pub/sub | Chat, live updates |
| Server-Sent Events | One-way HTTP | Redis pub/sub | Stock prices, logs |
| Long Polling | HTTP fallback | None needed | Legacy browser support |
| MQTT | Lightweight TCP | Broker cluster | IoT 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
| Metric | WebSocket | SSE | REST Polling (5s) |
|---|---|---|---|
| Latency (message delivery) | 2ms | 5ms | 2500ms avg |
| Connections per server | 10,000 | 8,000 | N/A |
| Memory per connection | 4KB | 6KB | N/A |
| Bandwidth (1000 msg/min) | 50KB/min | 80KB/min | 2.4MB/min |
| Reconnection time | 100ms | 300ms | N/A |
| CPU per 1000 connections | 2% | 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 plainws://. - 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
Originheader: 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=abcleaks 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-123should 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\nlets 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: offeringws://invites downgrade attacks — redirect or refuse it. - Don’t trust
X-Forwarded-Forblindly: 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
closeanddisconnecthandlers. - Works locally, fails in staging: proxies strip
Upgradeheaders — 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.
Related Resources
WebSocket Server
How to build a WebSocket server for bidirectional real-time communication, with connection management, message broadcasting, and heartbeat keepalive.
RecipeRate Limiting
How to implement API rate limiting using token bucket, sliding window, and fixed window algorithms across Python, JavaScript, and Java.
RecipeServer-Sent Events (SSE): One-Way Real-Time Streaming
Implement one-way real-time streaming from server to browser using Server-Sent Events. Covers Python, Node.js, Java, event types, reconnection, and broadcasting.
RecipeWebhooks
How to create and consume webhook endpoints for real-time event-driven integrations.
RecipeOpenAPI 3.1 API Docs with Swagger UI and Redoc
A practical guide to documenting REST APIs with OpenAPI. Generate interactive Swagger UI and Redoc docs in Python, JavaScript and Java with CI linting.
RecipeImplement Server-Sent Events in Go for Real-Time Updates
Build a production-ready Server-Sent Events endpoint in Go with connection management, heartbeat pings, and graceful client disconnect handling.