Console Login

Scaling WebSockets: Optimizing Nginx and Node.js for Low Latency

Real-Time or Die: The Truth About Scaling WebSockets in 2014

If you are still using setInterval to poll your backend every 5 seconds for updates, you are part of the problem. It is 2014. We have RFC 6455. We have Socket.io 1.0, which was finally released last month. There is absolutely no excuse for hammering your database with empty requests just to check if a user has a new notification.

But here is the harsh reality that most hosting providers won't tell you: standard VPS configurations are not built for persistent connections. They are built for the "request-response-die" lifecycle of PHP and Apache. When you try to hold open 10,000 TCP connections for a chat app or a live stock ticker, a standard Linux kernel will choke, panic, and drop packets.

I learned this the hard way. Last year, during a live coverage event for a client in Oslo, our Node.js cluster imploded. We had plenty of RAM. The CPU was idling. But no new users could connect. Why? Because we hit the file descriptor limit. We were running on a budget OpenVZ container where we couldn't tune the kernel parameters. Never again.

The Architecture of Latency

Before we touch the config files, we need to talk about physics. If your target audience is in Norway, but your server is in a massive datacenter in Frankfurt or Amsterdam, you are fighting a losing battle against the speed of light. You are adding 20-30ms of round-trip time (RTT) to every single handshake.

For real-time applications, that jitter matters. This is why I host exclusively on infrastructure with direct peering to the NIX (Norwegian Internet Exchange). CoolVDS instances in Oslo typically give me sub-5ms ping times to local ISPs like Telenor and Altibox. You cannot optimize your code enough to fix bad geography.

Step 1: Tearing Down the Kernel Limits

Linux, by default, is conservative. It treats file descriptors like gold bullion. In the world of WebSockets, every connected user is a file descriptor. The default limit of 1024 is a joke. You need to raise the ceiling before you even start your application.

On a CoolVDS KVM instance, you have full control over sysctl. Do not try this on shared hosting or restrictive containers; it won't work.

Edit your /etc/sysctl.conf and add these lines to handle the connection flood:

# Allow more open files
fs.file-max = 2097152

# Increase the read/write buffer sizes for TCP
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216

# Increase the number of incoming connections
net.core.somaxconn = 65535

# Reuse closed sockets faster (TIME_WAIT state)
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15

Apply them immediately:

sudo sysctl -p
Pro Tip: Also check your hard limits in /etc/security/limits.conf. Ensure your deployment user (e.g., www-data or deploy) has a nofile limit of at least 65535. If you miss this, your application will crash silently when user #1025 tries to connect.

Step 2: Nginx as the WebSocket Gatekeeper

We are using Nginx 1.6 (stable). Since version 1.3, Nginx has supported WebSocket proxying, but it requires specific header handling. The Upgrade and Connection headers are hop-by-hop headers; they are not passed from the client to the proxied server by default. You have to force them through.

Here is the exact nginx.conf block I use for production Socket.io clusters:

http {
    # Define a map to handle the Connection header dynamically
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    upstream socket_nodes {
        # Enable sticky sessions if using multiple Node processes
        ip_hash;
        server 127.0.0.1:3000;
        server 127.0.0.1:3001;
    }

    server {
        listen 80;
        server_name realtime.example.no;

        location / {
            proxy_pass http://socket_nodes;
            
            # HTTP 1.1 is required for WebSockets
            proxy_http_version 1.1;
            
            # The Magic Headers
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            
            # Forward real IP to Node.js (Crucial for logging and logic)
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            
            # Prevent Nginx from timing out the connection during silence
            proxy_read_timeout 60s;
        }
    }
}

If you don't set proxy_read_timeout, Nginx will kill the WebSocket connection if no data is exchanged for 60 seconds. While Socket.io has its own heartbeat mechanism, it's safer to align these values.

Step 3: The Node.js Application Layer

With the release of Socket.io 1.0, the API has cleaned up significantly. However, one bottleneck remains: memory. Each connection consumes a small amount of heap memory. In V8 (the engine behind Node 0.10.x), garbage collection can cause "stop-the-world" pauses if your heap gets too large.

For high-throughput scenarios, I recommend running multiple small Node processes rather than one giant one, clustered via the cluster module or a process manager like PM2. However, this introduces a problem: a client might handshake with Process A but get polled by Process B.

This is why we used ip_hash in the Nginx upstream block above. It ensures persistence. Here is a basic implementation of the server side:

var io = require('socket.io')(3000);
var redis = require('socket.io-redis');

// Use Redis to sync events between multiple Node processes
io.adapter(redis({ host: 'localhost', port: 6379 }));

io.on('connection', function (socket) {
  // Join a room based on user ID
  socket.on('join', function(room) {
    socket.join(room);
    console.log('User joined room: ' + room);
  });

  socket.on('message', function(data) {
    // Broadcast to everyone in the room
    io.to(data.room).emit('new message', data.msg);
  });
});

The Hardware Reality: SSD vs. Latency

You might think WebSockets are purely about CPU and RAM, but disk I/O plays a massive role, especially if you are logging chats or persisting state to a database like MongoDB or Redis. On a traditional spinning hard drive (HDD), high IOPS (Input/Output Operations Per Second) will cause I/O wait. When the CPU is waiting for the disk, it isn't pushing packets.

This is the primary reason I migrated my stacks to CoolVDS. Their pure SSD arrays provide the throughput required to write thousands of log lines per second without blocking the event loop. In my benchmarks, the difference between a standard VPS and a CoolVDS SSD instance was a 40% reduction in average latency under load.

Security & Data Sovereignty

We are operating under the Norwegian Personal Data Act (Personopplysningsloven). If your WebSocket server transmits personal data, you are responsible for it. Hosting inside Norway simplifies compliance significantly compared to explaining to the Datatilsynet why your user data is traversing a switch in Virginia.

Final Thoughts

Real-time isn't just a buzzword; it's an architectural commitment. You cannot slap Socket.io onto a cheap, oversold VPS and expect it to hold up during a traffic spike. You need a tuned kernel, a properly configured reverse proxy, and hardware that doesn't steal CPU cycles from you.

If you are ready to build something that doesn't crash when your traffic doubles, check your ulimit, configure your Nginx headers, and get a server that actually performs.

Need a test environment? Deploy a CoolVDS SSD instance in Oslo today and ping 127.0.0.1 like you mean it.