Backend
Scaling Event-Driven APIs: Real-Time WebSockets and Redis Pub/Sub for High-Concurrency Apps
Muhammad Tahir DEV Community
3 views
1. Introduction & Industry Context
In the landscape of modern application development, real-time interactivity has transitioned from a premium feature to a fundamental expectation. Whether architecting financial trading desks, collaborative multi-player whiteboards, live location trackers, or immediate notification engines, developers must deliver data-driven updates instantly. Historically, systems relied on HTTP short-polling or long-polling to mimic real-time feeds. However, these techniques suffer from immense HTTP header overhead, excessive TCP handshake cycles, and high latency, making them highly inefficient for modern workloads.
Today, the WebSocket Protocol (RFC 6455) remains the undisputed backbone of bidirectional, low-latency client-server communication. By initiating a single handshake over HTTP/1.1 or HTTP/2 and upgrading to a persistent, stateful TCP connection, WebSockets eliminate transmission overhead and enable sub-millisecond, full-duplex message exchanges. While newer technologies like WebTransport over HTTP/3 (RFC 9000) are emerging as high-performance alternatives for UDP-like streaming, WebSockets remain the most widely adopted, battle-tested standard in production environments.
However, the persistent nature of WebSockets introduces a fundamental engineering paradigm shift: they are stateful. While standard HTTP REST or GraphQL APIs are stateless and can scale horizontally behind a round-robin load balancer without friction, WebSocket connections tie a specific client directly to a specific server instance. This persistent affinity creates severe architectural challenges when scaling horizontally to support hundreds of thousands or millions of concurrent connections. This guide analyzes how to overcome these scaling barriers using an event-driven message broker topology driven by Redis Pub/Sub and Redis Streams.
2. The Core Problem & Business/Technical Impact
The primary bottleneck of real-time application scaling lies in the persistent connection state. When a client establishes a WebSocket connection with an application cluster, that connection is anchored to the memory and file descriptor table of a single, specific server node. If Client A is connected to Instance 1, and Client B is connected to Instance 2, they cannot naturally communicate with each other because Instance 1 has no knowledge of the connection state or memory space of Instance 2.
+----------+ +----------+ +----------+
| Client A | | Client B | | Client C |
+----+-----+ +----+-----+ +----+-----+
| | |
| (WebSocket) | (WebSocket) | (WebSocket)
v v v
+----+-----+ +----+-----+ +----+-----+
| Server 1 | | Server 2 | | Server 3 |
+----------+ +----------+ +----------+
^ ^
| How do we route a |
+---------------+ message from Server 1 ---------+
to Server 3 in real-time?
Leaving this synchronization problem unresolved leads to critical operational failures:
Split-Brain Messaging: Events published by a user on Server 1 are never broadcast to users connected to Server 2 or Server 3, leading to fragmented and broken real-time experiences.
File Descriptor Exhaustion: Operating systems enforce limits on the number of open files (including network sockets). Without fine-tuning, a high-concurrency WebSocket server will quickly exceed these limits, resulting in dropped connections and EMFILE: too many open files errors.
Memory Bloat and OOM Crashes: Every persistent socket connection consumes heap memory to maintain connection state and buffers. Uncontrolled memory allocation under high write volumes can trigger the Node.js Garbage Collector (GC) to freeze execution threads or cause the process to be terminated by the OS Out-of-Memory (OOM) killer.
No-Backpressure Cascades: If a slow client (e.g., a mobile device on a degraded 3G network) cannot ingest incoming events as fast as the server generates them, the server’s outbound buffer swells. Without proper backpressure management, this buffer bloat will eventually crash the server instance.
From a business perspective, system instability during peak usage events directly damages user retention, degrades brand trust, and triggers SLA violations. For e-commerce, financial platforms, and real-time gaming, a latency spike or dropped connection state can result in direct financial loss.
3. Architectural Concept & Solution Blueprint
To horizontally scale stateful connections, we must implement a Share-Nothing Architecture that decouples connection management from event orchestration. This is achieved by introducing a centralized, high-throughput message broker as an inter-node communication bus. When a server instance receives an event, it publishes it to the message broker, which immediately fans it out to all other active server instances. Each instance then delivers the message to its locally connected clients.
+----------+ +----------+ +----------+
| Client A | | Client B | | Client C |
+----+-----+ +----+-----+ +----+-----+
| | |
+----+-----+ +----+-----+ +----+-----+
| Server 1 | | Server 2 | | Server 3 |
+----+-----+ +----+-----+ +----+-----+
| | |
+--------+--------+--------+--------+
| (Pub/Sub Subscription)
v
+----------------------------------------------+
| Redis Cluster Bus |
+----------------------------------------------+
Choosing the Right Broker: Redis Pub/Sub vs. Redis Streams
Within the Redis ecosystem, we have two primary patterns for handling real-time event dissemination:
Redis Pub/Sub (Fire-and-Forget): Designed for maximum throughput and ultra-low latency. Redis Pub/Sub works by immediately pushing published messages to active subscribers. It does not store messages or write them to disk. It is highly optimized for real-time broadcasts (e.g., live chats or sports updates) where dropping a message for a disconnected client is acceptable, or where the client is expected to fetch missed state upon reconnection via a separate REST API.
Redis Streams (Durable, Ordered Queueing): Introduced to support persistent, multi-consumer event streaming. Unlike Pub/Sub, Redis Streams persists messages on disk, supports consumer groups, maintains message ordering, and allows clients to acknowledge processed messages. This pattern is essential when guaranteed delivery and message durability are required (e.g., order processing pipelines or audit logs).
For scaling real-time WebSocket messaging, a hybrid approach is often optimal: Redis Pub/Sub is used to handle high-frequency, low-latency inter-node fanning, while Redis Streams acts as the durable ledger to resolve connection drops, allowing disconnected clients to replay missed events from a sequence ID.
4. Step-by-Step Implementation
Let's construct a production-ready, horizontally scalable WebSocket backend using Node.js (v22+ LTS), the fast and lightweight ws library (v8.16+), and ioredis (v5.4+) for handling Redis operations. Our application will handle client connections, subscribe dynamically to Redis Pub/Sub channels, and gracefully manage lifecycle events and system backpressure.
Prerequisite: System Setup
Ensure you have Redis running locally or via a cloud instance (v7.4+ is recommended for advanced ACL management and optimized memory overhead).
Step 1: Initialize the Project
npm init -y
npm install ws ioredis
npm install --save-dev @types/ws @types/node typescript tsx
npx tsc --init
Step 2: Write the WebSocket & Redis Broker Implementation
Create a file named server.ts. This implementation features automatic heartbeat ping/pong cycles, sub-millisecond Pub/Sub event routing, clean connection tracking, and explicit client-side backpressure detection.
// Targets Node.js v22+ and ws v8.16+ / ioredis v5.4+
import { WebSocketServer, WebSocket } from 'ws';
import Redis from 'ioredis';
import { createServer } from 'http';
interface CustomWebSocket extends WebSocket {
isAlive: boolean;
connectionId: string;
channels: Set<string>;
}
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080;
const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
// Initialize high-performance Redis clients for publishing and subscribing
const pubClient = new Redis(REDIS_URL, { maxRetriesPerRequest: 3 });
const subClient = new Redis(REDIS_URL, { maxRetriesPerRequest: null });
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
// Local connection tracking map
const activeConnections = new Map<string, CustomWebSocket>();
// Channel subscriber tracking: maps a channel name to a set of connection IDs
const channelSubscriptions = new Map<string, Set<string>>();
/**
* Listen to the centralized Redis Pub/Sub channel and route incoming
* messages to the appropriate local WebSocket connections.
*/
subClient.on('message', (channel: string, messageStr: string) => {
const subscribers = channelSubscriptions.get(channel);
if (!subscribers || subscribers.size === 0) return;
let parsedMessage;
try {
parsedMessage = JSON.parse(messageStr);
} catch (err) {
console.error('Failed to parse Redis message payload', err);
return;
}
const payload = JSON.stringify({
channel,
data: parsedMessage,
timestamp: Date.now()
});
for (const connectionId of subscribers) {
const ws = activeConnections.get(connectionId);
if (ws && ws.readyState === WebSocket.OPEN) {
// Backpressure Check: Verify if the socket's outbound buffer is saturated
if (ws.bufferedAmount > 1024 * 1024) { // 1MB buffer limit
console.warn(`[Backpressure] Client ${connectionId} is too slow. Dropping message.`);
continue;
}
ws.send(payload);
}
}
});
// Handle incoming HTTP connection upgrades
server.on('upgrade', (request, socket, head) => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
// Connection lifecycle handler
wss.on('connection', (ws: CustomWebSocket) => {
ws.isAlive = true;
ws.connectionId = Math.random().toString(36).substring(2, 15);
ws.channels = new Set<string>();
activeConnections.set(ws.connectionId, ws);
console.log(`[Connected] Client ID: ${ws.connectionId}`);
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', async (data: string) => {
try {
const message = JSON.parse(data.toString());
const { action, channel, payload } = message;
switch (action) {
case 'subscribe':
if (!channel) return;
ws.channels.add(channel);
if (!channelSubscriptions.has(channel)) {
channelSubscriptions.set(channel, new Set());
// Tell Redis to subscribe this node to the channel
await subClient.subscribe(channel);
console.log(`[Redis Subscribe] Subscribed node to Redis channel: ${channel}`);
}
channelSubscriptions.get(channel)!.add(ws.connectionId);
ws.send(JSON.stringify({ status: 'subscribed', channel }));
break;
case 'unsubscribe':
if (!channel) return;
ws.channels.delete(channel);
const subs = channelSubscriptions.get(channel);
if (subs) {
subs.delete(ws.connectionId);
if (subs.size === 0) {
channelSubscriptions.delete(channel);
await subClient.unsubscribe(channel);
console.log(`[Redis Unsubscribe] Unsubscribed node from channel: ${channel}`);
}
}
ws.send(JSON.stringify({ status: 'unsubscribed', channel }));
break;
case 'publish':
if (!channel || !payload) return;
// Distribute event across the Redis cluster cluster-wide
await pubClient.publish(channel, JSON.stringify(payload));
break;
default:
ws.send(JSON.stringify({ error: 'Unknown action protocol' }));
}
} catch (err) {
ws.send(JSON.stringify({ error: 'Invalid JSON payload payload' }));
}
});
ws.on('close', () => {
console.log(`[Disconnected] Client ID: ${ws.connectionId}`);
cleanupConnection(ws);
});
ws.on('error', (err) => {
console.error(`[Error] Socket error on Client ${ws.connectionId}:`, err);
cleanupConnection(ws);
});
});
/**
* Safely clean up memory footprint and unsubscribe vacant channels
*/
function cleanupConnection(ws: CustomWebSocket) {
activeConnections.delete(ws.connectionId);
for (const channel of ws.channels) {
const subs = channelSubscriptions.get(channel);
if (subs) {
subs.delete(ws.connectionId);
if (subs.size === 0) {
channelSubscriptions.delete(channel);
// Run in background asynchronously
subClient.unsubscribe(channel).catch(err =>
console.error(`Failed to unsubscribe from channel ${channel}`, err)
);
}
}
}
}
/**
* Heartbeat Interval: Regularly ping clients to clear zombie connections.
* This prevents ghost sockets from consuming system memory and open files.
*/
const interval = setInterval(() => {
wss.clients.forEach((client) => {
const ws = client as CustomWebSocket;
if (ws.isAlive === false) {
console.log(`[Terminating Zombie] Client ID: ${ws.connectionId}`);
cleanupConnection(ws);
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => {
clearInterval(interval);
});
server.listen(PORT, () => {
console.log(`[Server] Live on http://localhost:${PORT}`);
});
5. Performance Optimization & Best Practices
To successfully maintain low latencies at high concurrent scale, optimizing the networking stack and handling backpressure is critical.
OS-Level Adjustments (sysctl and TCP Tuning)
By default, Linux limits file descriptors per process to 1024. Each WebSocket connection counts as an open file descriptor. To support more concurrent connections, you must modify standard OS system parameters.
Add the following lines to your server configuration file /etc/sysctl.conf and reload using sysctl -p:
# Increase maximum open files globally
fs.file-max = 2097152
# Increase range of local ephemeral ports
net.ipv4.ip_local_port_range = 1024 65535
# Maximize TCP connection queue backlog sizes
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 16384
In your deployment environment (systemd config or Docker container configuration), increase limits for the application process explicitly:
ulimit -n 65536
Managing Backpressure and Buffer Bloat
When a fast sender sends messages to a slow receiver, the outbound buffer on the WebSocket object swells. In Node.js, ws.bufferedAmount reports the number of bytes queued for transmission over the network.
To prevent memory exhaustion, always monitor ws.bufferedAmount before sending messages. If the buffer size exceeds your defined limit (e.g., 1MB), you must implement defensive mitigations:
Throttling/Dropping: Drop non-critical updates (e.g., intermediate real-time coordinates) and preserve only the final state.
Slow Client Disconnection: Disconnect clients that fail to consume messages for a prolonged period to reclaim resources.
Memory Footprint Reduction
To prevent Garbage Collection thrashing when serving over 50,000 clients per node, minimize allocating short-lived objects inside message loops. Reuse buffer structures and send pre-serialized JSON strings from Redis directly, rather than parsing and re-serializing objects on every single push.
6. Business ROI & Future Outlook
Transitioning from stateful monolithic instances to a horizontally scalable architectural design built on Redis and WebSockets yields significant return on investment (ROI):
Optimization Layer
Before Implementation
After Scaling Implementation
Business Impact / ROI
Server Memory Footprint
Heap spikes & regular OOM crashes
Uniform, bounded heap utilization
40% reduction in cloud compute costs
User Latency
High overhead from polling (100ms+)
Consistent sub-millisecond broadcasts
Improved User Engagement (INP optimized)
Autoscaling Stability
Broken states, dropped user sessions
Dynamic instance scaling with state persistence
High-availability SLA (>99.99% uptime)
Future Outlook: WebTransport over HTTP/3
Looking toward the future of real-time systems, WebTransport is positioned to address some of the long-standing challenges of the TCP protocol. Operating over QUIC (HTTP/3), WebTransport supports multiplexed streams, rapid connections, and offers both reliable and unreliable datagram transfers (similar to UDP).
However, because WebTransport is not yet fully standardized across all enterprise proxies and web application firewalls, the hybrid design of horizontally scaled WebSockets via Redis Pub/Sub will remain the primary and most robust standard for high-concurrency systems for the foreseeable future.
7. Conclusion & Key Takeaways
Scaling real-time event-driven APIs is primarily a challenge of managing persistent connection states and network backpressure. By decoupling connection management from message flow through the use of a high-throughput broker like Redis Pub/Sub, developers can easily scale application instances horizontally.
Key Takeaways:
State Decoupling: Never store state on local WebSocket servers. Offload messaging patterns dynamically using Redis Pub/Sub or Redis Streams.
Defend Against Slow Clients: Explicitly monitor ws.bufferedAmount to prevent buffer bloat and memory leaks.
Optimize Your OS Host: Increase file descriptors (ulimit -n) and adjust network system properties (sysctl.conf) to support thousands of concurrent TCP sockets.
Clean Up Zombie Sockets: Establish explicit ping/pong loops to identify and remove stale connections, protecting system resources.
By adopting these design principles and utilizing the provided reference implementation, your applications will remain resilient, highly available, and scalable under extreme concurrent workloads.
Read original: https://dev.to/mtahir27/scaling-event-driven-apis-real-time-websockets-and-redis-pubsub-for-high-concurrency-apps-90b
← Previous
Agentic Synthetic Data Generation
Next →
React `startTransition` Without `useTransition`: The Standalone API Teams Keep Overlooking in Concurrent Mode
Related
Mainframe Modernisation: Rewrite, Refactor or Replatform
Backend
0
Dev.to (EN Zone)
Engineering Build Notes #3: When 200Gi Was More Storage Than the Nodes Needed
Backend
1
DEV Community
Getting Mac Air m2/m3/m4 is it good [D]
Backend
5
Reddit r/MachineLearning
PHP Driven Crypto Commerce Project - Paybyte
Backend
5
Reddit r/php
Comments0
No comments yet — be the first