Skip to main content

Β·

Scaling WebSocket Servers

Scaling WebSocket Infrastructure with Socket.IO, Redis, and Traefik

WebSocket is a widely adopted protocol that enables full-duplex communication over TCP. Several libraries implement this protocol, and one of the most popular and powerful options in the JavaScript ecosystem is Socket.IO, which makes it easy to build real-time communication systems.
Building a WebSocket server on a single machine is relatively straightforward. However, when the number of users grows rapidly and the system receives a massive volume of requests, building scalable WebSocket infrastructure becomes an essential yet challenging task.
In this article, we'll explore the challenges of scaling WebSocket infrastructure and the key solutions needed to overcome them, with the goal of building a multi-server WebSocket cluster that meets real-world requirements.

Challenges in Scaling WebSocket

Scaling HTTP

To understand why scaling WebSocket connections can be challenging, let's compare WebSocket with HTTP, a protocol most developers are already familiar with.
With HTTP, communication typically follows a request-response pattern. You generally don't expect a client's next request to return to the same server instance. In fact, relying on that behavior can introduce sticky-session dependencies, making it harder to scale for performance and redundancy.
With HTTP, you can run almost any number of web server instances behind a load balancer. The load balancer forwards each request to an available server, which processes the request and returns a response to the client. HTTP requests are often short-lived from an application perspective: the server processes a request, sends a response, and finishes handling that request.

Scaling WebSocket

WebSocket connections, on the other hand, are persistent. A WebSocket client opens a connection to a server and reuses it for ongoing communication. Over this long-lived connection, both the client and server can send messages and respond to events. This is known as full-duplex communication.
A connection can be established through a load balancer, but once the connection is upgraded, it remains attached to the same backend server until it is closed or interrupted.
This introduces stateful interactions. In practice, a WebSocket server may need to maintain connection-specific data in memory for every connected client. For example, it might track which user is associated with a socket and what data that user is interested in receiving.
Persistent connections are what make WebSocket so powerful for real-time applications, but they also introduce additional challenges when scaling horizontally across multiple servers.

Theoretical Analysis

Objective

Build a scalable WebSocket cluster capable of maintaining connections between multiple clients and multiple servers.

Problems

There are two main challenges to address:
  • Cross-server communication: Multiple WebSocket servers must coordinate with one another. When a server receives a message from a client, it must ensure that all relevant clients connected to any server in the cluster can receive that message.
  • Connection affinity: Once a client establishes a connection with a particular server, its subsequent communication must continue through that connection. Otherwise, another server may not recognize the client's existing session or connection state.

Solutions

Problem 1: Cross-server communication
This problem can be addressed using an adapter. Socket.IO's default in-memory adapter supports broadcasting events among clients connected to a single server process. However, it cannot coordinate broadcasts across multiple independent server instances.
For a multi-server deployment, a Redis-based adapter can distribute events across the cluster using Redis Pub/Sub.
In the original Socket.IO ecosystem, socket.io-redis was a commonly used adapter. For modern Socket.IO deployments, the recommended package is generally @socket.io/redis-adapter.
The basic configuration using the legacy socket.io-redis package looks like this:
const redisAdapter = require("socket.io-redis"); const redisHost = process.env.REDIS_HOST || "localhost"; io.adapter( redisAdapter({ host: redisHost, port: 6379, }) );
With a compatible Redis adapter configured, broadcasts can be distributed across multiple Socket.IO server instances, allowing clients connected to different instances to receive the same events.
Problem 2: Maintaining connection affinity
The second problem is ensuring that a client continues to communicate with the server handling its session.
This can be addressed by placing a reverse proxy in front of the Node.js application servers. Common choices include NGINX, HAProxy, Apache HTTP Server, and Traefik.
The reverse proxy distributes incoming connections among the available servers and can use sticky sessions when required by the transport and deployment configuration.
For WebSocket connections, the established TCP connection itself remains attached to its selected backend. However, if Socket.IO uses HTTP long polling, multiple HTTP requests may be required for a single session. In that case, sticky sessions are necessary unless the deployment uses an alternative architecture that explicitly handles this requirement.

Summary

With this configuration, the expected behavior is:
  • Each client establishes a connection to a specific WebSocket server instance.
  • Messages sent over an established WebSocket connection continue through that connection to the same server instance.
  • When a server receives an event that needs to be broadcast, the adapter distributes it to other server instances, which forward it to their connected clients.

Tutorials

Step 1: Set Up a WebSocket Server with a Redis Adapter

The following example creates a Socket.IO server, configures a Redis adapter, handles client events, and periodically broadcasts a health message.
const os = require("os"); const ifaces = os.networkInterfaces(); const privateIp = (() => { return Object.values(ifaces) .flat() .find((val) => val.family === "IPv4" && !val.internal) .address; })(); const randomOffset = Math.floor(Math.random() * 10); const intervalOffset = (30 + randomOffset) * 1000; // WebSocket server const socketPort = 5000; const socketServer = require("http").createServer(); const io = require("socket.io")(socketServer, { path: "/", }); // Redis adapter const redisAdapter = require("socket.io-redis"); const redisHost = process.env.REDIS_HOST || "localhost"; io.adapter( redisAdapter({ host: redisHost, port: 6379, }) ); // Event handlers io.on("connection", (client) => { console.log("New incoming connection from", client.id); client.on("test000", (message) => { console.log("Message from client:", client.id, "->", message); }); }); setInterval(() => { const log0 = `I am the host: ${privateIp}. I am healthy.`; console.log(log0); io.emit("okok", log0); }, intervalOffset); // Start listening socketServer.listen(socketPort);
Each server instance connects to Redis through the adapter. When one instance broadcasts an event, the adapter distributes it to the other instances in the cluster.

Step 2: Build a Docker Image for the WebSocket Server

Create a Dockerfile:
# Stage 1: Install dependencies FROM node:13-alpine AS node-compiled ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} RUN apk --no-cache add python make g++ COPY package*.json ./ RUN npm install # Stage 2: Build the application image FROM node:13-alpine WORKDIR /usr/src/app COPY . . COPY --from=node-compiled node_modules node_modules # EXPOSE 5000 CMD ["npm", "run", "start"]
Build the image:
docker build . -t ws_local
Note: Node.js 13 is end-of-life. For a new deployment, use a currently supported Node.js LTS release, pin an appropriate image version, and consider using npm ci with a committed lockfile for reproducible dependency installation.

Step 3: Create the docker-compose.yaml File

The following configuration uses Docker Swarm-style replication, Traefik as the reverse proxy, and Redis as the messaging layer.
version: "3.2" services: socket-server: # build: . # image: sw360cab/wsk-base:0.1.1 image: websocket_local # restart: always deploy: mode: replicated replicas: 2 environment: - "REDIS_HOST=redis" labels: - "traefik.enable=true" - "traefik.http.routers.socket-router.rule=PathPrefix(`/wsk`)" - "traefik.http.services.service01.loadbalancer.server.port=5000" - "traefik.http.services.service01.loadbalancer.sticky.cookie=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.name=io" - "traefik.http.services.service01.loadbalancer.sticky.cookie.httponly=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.secure=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.samesite=lax" # Replace the /wsk prefix - "traefik.http.middlewares.socket-replaceprefix.replacepath.path=/" # Attach the middleware to the router - "traefik.http.routers.socket-router.middlewares=socket-replaceprefix@docker" traefik-reverse-proxy: image: traefik:v2.2 command: - "--api.insecure=true" - "--providers.docker.exposedByDefault=false" - "--accesslog" # - "--entryPoints.web.address=:80" # - "--entryPoints.web.forwardedHeaders.insecure=true" ports: - "80:80" # Web UI (enabled by --api.insecure=true) - "8080:8080" volumes: # Allow Traefik to monitor Docker events - /var/run/docker.sock:/var/run/docker.sock redis: image: redis:5.0

Understanding the Configuration

Let's examine the Traefik reverse proxy configuration:
traefik-reverse-proxy: image: traefik:v2.2 command: - "--api.insecure=true" - "--accesslog" ports: - "80:80" # Web UI (enabled by --api.insecure=true) - "8080:8080" volumes: # Allow Traefik to monitor Docker events - /var/run/docker.sock:/var/run/docker.sock
The important part is mounting the Docker socket so that Traefik can access Docker's API and monitor configuration changes in the container environment.
The required routing configuration is defined dynamically through Docker labels attached to the WebSocket application service.
For example:
socket-server: image: sw360cab/wsk-base:0.1.1 restart: always deploy: mode: replicated replicas: 2 environment: - "REDIS_HOST=redis" labels: - "traefik.http.routers.socket-router.rule=PathPrefix(`/wsk`)" - "traefik.http.services.service01.loadbalancer.server.port=5000" - "traefik.http.services.service01.loadbalancer.sticky.cookie=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.name=io" - "traefik.http.services.service01.loadbalancer.sticky.cookie.httponly=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.secure=true" - "traefik.http.services.service01.loadbalancer.sticky.cookie.samesite=lax" - "traefik.http.middlewares.socket-replaceprefix.replacepath.path=/" - "traefik.http.routers.socket-router.middlewares=socket-replaceprefix@docker"
Note: In this configuration, the WebSocket application is exposed under /wsk rather than the root path. This allows us to demonstrate how Traefik middleware works.
These labels define three main components:
  • Router: The router matches incoming requests whose paths begin with /wsk.
  • Service: The service forwards requests to port 5000, where the WebSocket application listens. It also configures sticky sessions through cookies.
  • Middleware: The middleware replaces the incoming path with /, matching the path expected by the WebSocket application.
The following label attaches the middleware to the router:
- "traefik.http.routers.socket-router.middlewares=socket-replaceprefix@docker"
The middleware reference follows the format <middleware_name>@<provider_name>, where docker identifies the configuration provider.
The names of the router (socket-router), service (service01), and middleware (socket-replaceprefix) are arbitrary and can be changed to suit your naming conventions.
Deployment considerations: This is a demonstration configuration, not a production-ready setup. In particular, the insecure Traefik dashboard, unrestricted Docker socket access, Redis without authentication, and the use of old software versions should be addressed before deploying to production. Also, the secure sticky-cookie setting requires HTTPS for the cookie to be sent by browsers.

Step 4: Deploy the Docker Swarm Stack

Deploy the stack using the following command:
docker stack deploy --compose-file stack/docker-compose.yml wsk
Next, open the Traefik dashboard, which is exposed at http://localhost:8080 in this example. If the configuration is loaded without errors, you can start connecting your WebSocket clients.
Remember to update the client configuration: clients should now connect to Traefik, which exposes the WebSocket service under /wsk.

Step 5: Launch the WebSocket Client

The following client example connects through the reverse proxy.
The port depends on your deployment environment:
  • 5000 β€” Direct connection to the WebSocket server.
  • 80 β€” Connection through an HTTP reverse proxy such as Traefik or HAProxy.
  • 30000 β€” Example NodePort when using Kubernetes.
  • 30080 β€” Example port when using Traefik Ingress in Kubernetes.
Example client:
const uuid = require("uuid"); const io = require("socket.io-client"); const client = io("http://localhost:80", { // port: 5000 - direct connection // port: 80 - through an HTTP reverse proxy // port: 30000 - through a Kubernetes NodePort // port: 30080 - example Traefik Ingress port path: "/wsk", // when using the Traefik configuration above // path: "/", // for other configurations reconnection: true, reconnectionDelay: 500, transports: ["websocket"], }); const clientId = uuid.v4(); let disconnectTimer; client.on("connect", () => { console.log("Connected!", clientId); setTimeout(() => { console.log("Sending first message"); client.emit("test000", clientId); }, 500); // Clear the disconnection timeout clearTimeout(disconnectTimer); }); client.on("okok", (message) => { console.log("The server has a message for you:", message); }); client.on("disconnect", () => { console.log("Disconnected!"); disconnectTimer = setTimeout(() => { console.log("Not reconnecting in 10 seconds. Exiting..."); process.exit(0); }, 10000); }); client.on("connect_error", (err) => { console.error(err); }); setInterval(() => { console.log("Sending repeated message"); client.emit("test000", clientId); }, 5000);
Run the client:
node client_socket.js
And there you have it! Communication between the client and the WebSocket service should continue to work across the clustered environment.
The complete tutorial source code, along with additional configuration examples, is available in the websockets-scaling repository.

Conclusion

Scaling WebSocket infrastructure is a practical requirement for real-world applications, but it introduces challenges that are more complex than those encountered with traditional HTTP servers.
With stateless HTTP request handling, adding more server instances behind a load balancer is often relatively straightforward. WebSocket introduces persistent connections and connection-specific state, requiring additional considerations when scaling horizontally.
This article examined the two major challenges of scaling a WebSocket cluster and explored how Redis-based Pub/Sub and a reverse proxy can help address them.
We also walked through a practical setup for building a scalable WebSocket cluster using Socket.IO, a Redis adapter, Traefik, and Docker Swarm.
In a real-world deployment, you can choose other Pub/Sub adapters and reverse proxies, such as NGINX or HAProxy, or use an orchestration platform such as Kubernetes.
The key is to design the system so that connections remain correctly routed, events can be distributed across server instances, and the infrastructure can scale to meet growing demand.

References

Subscribe to the newsletter

Get new posts on Backend, Blockchain, DevOps, and life notes.

Loading Comments...