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

Real-time notifications keep users informed without polling. WebSockets provide full-duplex communication between client and server, while Redis pub/sub acts as a message broker to broadcast events across multiple server instances.

The code below implements a notification system with WebSocket connections, room-based broadcasting, and Redis-backed horizontal scaling.

When to Use

Use this resource 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
  • You need to broadcast the same event to many connected clients

Solution

Python

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

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

class ConnectionManager:
    def __init__(self):
        self.active_connections: list[WebSocket] = []

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections.append(websocket)

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

manager = ConnectionManager()

@app.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket):
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_text()
            await redis_client.publish("notifications", data)
    except WebSocketDisconnect:
        manager.active_connections.remove(websocket)

# Redis subscriber (run in background task)
async def redis_listener():
    pubsub = redis_client.pubsub()
    await pubsub.subscribe("notifications")
    async for message in pubsub.listen():
        if message["type"] == "message":
            await manager.broadcast(message["data"].decode())

JavaScript

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

const wss = new WebSocket.Server({ port: 8080 });
const clients = new Set();

wss.on('connection', (ws) => {
  clients.add(ws);
  ws.on('close', () => clients.delete(ws));
});

subscriber.subscribe('notifications');
subscriber.on('message', (channel, message) => {
  for (const client of clients) {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message);
    }
  }
});

// Publish from API
redis.publish('notifications', JSON.stringify({ type: 'alert', text: 'New order!' }));

Java

import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Component
public class NotificationHandler extends TextWebSocketHandler {
    private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.add(session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        sessions.remove(session);
    }

    public void broadcast(String message) {
        for (WebSocketSession session : sessions) {
            session.sendMessage(new TextMessage(message));
        }
    }
}

@Component
public class RedisNotificationListener implements MessageListener {
    @Autowired private NotificationHandler handler;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        handler.broadcast(new String(message.getBody()));
    }
}

Explanation

The architecture consists of three layers:

  • WebSocket layer: Maintains persistent connections with clients
  • Redis pub/sub: Distributes messages across server instances (no persistence)
  • Application layer: Publishes events when business actions occur

Redis pub/sub is ideal for broadcasting because subscribers receive messages in real-time without polling. For persistence, use Redis Streams or a message queue like RabbitMQ.

Variants

TechnologyTransportGrowthUse 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

What Works

  • Heartbeat/ping every 30 seconds: Detect dead connections and free resources
  • Room/channel segmentation: Broadcast to subsets of users, not all connections
  • Authentication on handshake: Validate JWT during WebSocket upgrade
  • Graceful degradation: Fall back to SSE or polling if WebSockets fail
  • Rate limit broadcasts: Prevent spam from overwhelming clients

Common Mistakes

  • Not handling reconnections: Clients disconnect — implement exponential backoff reconnection
  • Storing messages in Redis pub/sub: Pub/sub does not persist messages; use Redis Streams for durability
  • Broadcasting to all clients: Use room/channel namespaces to limit message delivery
  • Ignoring connection limits: Each WebSocket consumes memory; set per-IP and global limits
  • Missing auth on handshake: Authenticate during the upgrade request with JWT, not after connection

When Not to Use This Approach

  • Browser-facing APIs with no real-time need: if your API only serves request-response patterns, adding WebSocket/SSE infrastructure is unnecessary overhead. Stick with REST.
  • Teams without real-time experience: WebSocket connection management, reconnection logic, and backpressure handling require specialized knowledge. If your team is small, REST polling may be more reliable.
  • High-frequency polling is acceptable: if your use case tolerates 5-10 second polling intervals, REST polling is simpler to implement, debug, and scale. Real-time infrastructure is only justified when latency matters.
  • Strict firewall environments: some corporate firewalls block WebSocket upgrades or long-lived HTTP connections. Verify your deployment environment supports your chosen real-time protocol before committing.
  • Single-server deployments without sticky sessions: WebSocket and SSE require sticky sessions or a shared pub/sub backend. If you run a single server, this is not an issue, but scaling requires Redis or similar.

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%

Benchmarks run on Node.js 20, single core, 1KB messages. Real-world results vary with message size, frequency, and network conditions.

Testing Strategy

  • Test connection lifecycle: verify connect, authenticate, message exchange, and disconnect work correctly.
  • Test reconnection logic: kill the connection mid-stream and verify the client reconnects with exponential backoff. Verify no messages are lost during reconnection (use sequence numbers).
  • Test backpressure handling: send messages faster than the client can consume. Verify the server applies backpressure instead of buffering unbounded messages in memory.
  • Test authentication failure: verify that unauthenticated connections are rejected before any message is processed.
  • Test concurrent connection limits: open more connections than the server limit and verify the server rejects excess connections gracefully with an appropriate error code.
  • Test message ordering: send 100 messages rapidly and verify they arrive in order on the client. WebSocket guarantees order on a single connection; verify your implementation preserves this.

Cost Estimation

  • Infrastructure cost: real-time servers require more memory per connection (4-6KB vs 0KB for stateless REST). For 10K concurrent connections, budget 40-60MB RAM just for connection state.
  • Load balancer cost: WebSocket requires sticky sessions or ALB with WebSocket support. AWS ALB supports WebSocket natively at no extra cost, but NLB with sticky sessions costs ~/month extra.
  • Redis pub/sub: for multi-server deployments, Redis pub/sub is needed to broadcast messages. A small Redis instance (~/month) handles up to 10K subscriptions.
  • Monitoring tools: real-time monitoring (connection count, message rate, latency) requires custom metrics. Budget -50/month for Datadog or Grafana Cloud.
  • Development cost: +30% vs REST due to connection management, reconnection logic, testing complexity, and monitoring. Amortized over the API lifetime.

Monitoring and Observability

  • Track concurrent connection count: monitor active WebSocket/SSE connections per server instance. Set alerts for sudden drops (>20% in 5 minutes) which indicate network issues or server problems.
  • Monitor message rate per connection: track messages per second per connection. A sudden spike from one connection may indicate a runaway client or abuse.
  • Track reconnection rate: monitor how often clients reconnect. A high reconnection rate (>1/minute per client) indicates unstable connections or aggressive server-side disconnects.
  • Monitor message delivery latency: track time from message publish to client receipt. Latency >100ms indicates server backlog or network issues.
  • Track authentication failures: monitor failed auth attempts per IP. A spike may indicate credential stuffing or token replay attacks.

Deployment Checklist

  • Configure connection timeout (idle connections should be closed after 5 minutes)
  • Set max connections per server instance (prevent resource exhaustion)
  • Enable heartbeat/ping-pong to detect dead connections
  • Configure sticky sessions on load balancer (for WebSocket)
  • Set up Redis pub/sub for multi-server message broadcasting
  • Enable TLS/wss for all production connections
  • Configure reconnection logic on client with exponential backoff
  • Set up monitoring for connection count, message rate, and latency
  • Test failover: kill one server and verify clients reconnect to another
  • Document message format and protocol in API documentation

Security Considerations

  • Origin validation: WebSocket connections send an Origin header. Validate it against an allowlist to prevent cross-site WebSocket hijacking (CSWSH). Reject connections from unknown origins.
  • Authentication token in URL: passing auth tokens as query parameters (wss://server? token=abc) leaks tokens in server logs and proxy access logs.
  • Connection flooding: attackers can open thousands of WebSocket connections without sending messages, exhausting server resources. Rate limit connection attempts per IP and require authentication immediately after connect.
  • Message size limits: set a max message size on the server. Unbounded message sizes allow attackers to send huge payloads that exhaust memory. A 1MB limit is reasonable for most use cases.
  • Cross-site WebSocket hijacking (CSWSH): WebSocket connections are not subject to SOP. Any web page can open a WebSocket to your server. Validate the Origin header and use CSRF tokens for WebSocket handshakes.
  • Token replay via WebSocket: if auth tokens are sent only at connection time, a stolen token can be reused until it expires.
  • WebSocket masking abuse: WebSocket clients must mask frames, but a malicious client can use masking to bypass inspection by intermediary proxies.
  • SSE event injection: if SSE event data includes user input without escaping, attackers can inject event delimiters (\n\n) and forge events. Always sanitize user input in SSE messages.
  • Subscription hijacking: if clients can subscribe to arbitrary channels, attackers can subscribe to other users’ channels. Validate that the client is authorized for each subscription.
  • Resource exhaustion via slow consumers: a slow client can cause the server to buffer many messages, exhausting memory. Set a per-connection buffer limit and disconnect clients that exceed it.
  • Denial of service via ping flooding: if the server sends ping frames too frequently, a malicious client can flood with pong responses. Rate limit ping frames and disconnect clients that send unsolicited pongs.
  • WebSocket extension abuse: WebSocket extensions (e. g. , permessage-deflate) can be abused to send highly compressed frames that decompress to huge payloads. Set a max decompressed frame size.
  • Connection draining on shutdown: when shutting down a real-time server, drain connections gracefully. Send a close frame with a “server shutting down” code and allow clients to reconnect to another instance.
  • Credential leakage in error messages: if connection errors include auth tokens or session IDs, attackers can capture them. Never include sensitive data in error messages sent to clients.
  • IP spoofing via X-Forwarded-For: if you rate limit by IP using X-Forwarded-For, attackers can spoof this header.
  • Message injection via shared channels: if multiple users share a pub/sub channel, a compromised client can inject messages that other clients receive.
  • Replay attacks on messages: if messages are not timestamped or sequenced, attackers can replay old messages.
  • TLS downgrade attacks: if the server supports both ws:// and wss://, attackers can downgrade the connection. Disable ws:// in production and redirect to wss://.
  • Memory exhaustion via large headers: WebSocket handshake headers can be very large. Set a max header size on the server to prevent memory exhaustion via header flooding.
  • Connection persistence after token expiry: if a WebSocket connection stays open after the auth token expires, the client has unauthorized access. Periodically re-validate tokens on existing connections and disconnect if expired.
  • Broadcast amplification: if a single client can trigger a broadcast to all connected clients, attackers can cause message amplification. Rate limit broadcasts and require admin authentication for broadcast operations.
  • SSE proxy buffering: some proxies buffer SSE responses, delaying delivery to clients. Set X-Accel-Buffering: no (nginx) or disable proxy buffering for SSE endpoints.
  • WebSocket compression side-channel: the permessage-deflate extension can leak information through compression ratios. Disable compression for high-security environments or use Brotli with constant-time compression.
  • Channel enumeration: if channel names are guessable (e. g. , user-123), attackers can enumerate channels.
  • Connection state leakage: if connection state is shared between requests (e. g. , in a shared channel object), data from one user may leak to another.
  • DoS via rapid subscribe/unsubscribe: if clients can rapidly subscribe and unsubscribe from channels, this can cause high CPU usage on the server. Rate limit subscription changes per connection.
  • Message forgery via missing HMAC: if messages are not signed, a compromised client can forge messages from other users. Sign each message with an HMAC using a per-user secret.
  • Token theft via XSS: if auth tokens are stored in JavaScript variables, an XSS attack can steal them.
  • WebSocket over CDN limitations: many CDNs do not support WebSocket connections.
  • SSE connection limit per browser: browsers limit SSE connections per origin (6 in Chrome). If your app opens multiple SSE connections, some will fail.
  • Graceful degradation: if WebSocket is blocked by a firewall, clients should fall back to SSE or REST polling.

Troubleshooting

  • 5xx errors under load: check rate limits, connection pools, and downstream timeouts.
  • CORS errors in the browser: confirm allowed origins, methods, and headers. Preflight requests must return the right headers before the actual request.
  • Unexpected 404s: verify route definitions, path parameters, and base paths. Watch for trailing slashes and URL encoding differences.
  • Authentication failures: validate token expiry, signature algorithms, and clock skew. Log rejected tokens without exposing secrets.
  • Slow response times: profile the slowest percentiles.

Production Notes

  • Deploy gradually using canary or blue-green to catch regressions early.
  • Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
  • Document the rollback in the runbook; test the procedure in staging at least once per quarter.
  • Review structured logs with correlation IDs to trace requests end-to-end during incidents.

Key Takeaways

  • Apply build real-time notifications with websockets when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Common Production Pitfalls

  • Copying the example without adapting it to real data volumes and failure modes.
  • Skipping load and error-injection tests before the first production deployment.
  • Hard-coding values that should be configurable per environment.
  • Forgetting to add logging and monitoring at each step.
  • Deploying without a rollback plan or a tested backup strategy.
  • Assuming the minimal example will scale without adding caching or batching.
  • Not documenting the version and configuration used in production.
  • Letting the recipe sit unchanged when dependencies or scale evolve.

Frequently Asked Questions

How many concurrent WebSockets can one server handle?

Node.js handles ~10k-50k, Go ~100k+, Java (Netty) ~1M+. Use load testing with your actual payload size to determine real limits.

Can I use WebSockets with serverless functions?

AWS API Gateway supports WebSockets, but stateless functions require DynamoDB or Redis to share connection info. Consider service mesh patterns for scaling real-time infrastructure.

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).