Ludo Game Load Balancing: Nginx, Sticky Sessions, Health Checks

Load balancing is the routing layer that distributes thousands of concurrent Ludo game connections across your server fleet without players noticing. Unlike stateless REST APIs where any server can handle any request, WebSocket-based multiplayer Ludo games have state — a player's WebSocket connection is bound to a specific game server instance. This creates a fundamental challenge: how do you balance connections fairly while keeping each player's session on the same server throughout a match? This guide covers Nginx configuration for WebSocket-aware load balancing, sticky session implementation, active health checks, and zero-downtime deployment strategies that keep Ludo matches running through server upgrades.

Nginx Upstream Configuration for WebSocket

Nginx's upstream module distributes WebSocket connections using the IP hash algorithm, which consistently routes the same client IP to the same backend server. This satisfies the sticky session requirement for Ludo's stateful WebSocket connections. The key configuration nuance is the Upgrade and Connection header passing, which enables Nginx to properly handle the WebSocket handshake and keep-alive connections.


# /etc/nginx/conf.d/ludo-game.conf

upstream ludo_game_backend {
  # IP hash ensures same client IP always hits same backend (sticky sessions)
  ip_hash;

  # Game server instances — scale by adding more lines
  server 10.0.1.10:3000 max_fails=3 fail_timeout=30s;
  server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
  server 10.0.1.12:3000 max_fails=3 fail_timeout=30s;

  keepalive 64;  # Keep connections alive to upstream — reduces latency
}

server {
  listen 443 ssl http2;
  server_name api.ludokingapi.site;

  ssl_certificate     /etc/ssl/ludokingapi.site.crt;
  ssl_certificate_key /etc/ssl/ludokingapi.site.key;

  # REST API endpoints — standard proxy
  location /api/ {
    proxy_pass         http://ludo_game_backend;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;

    # HTTP 1.1 needed for keep-alive to upstream
    proxy_http_version 1.1;
    proxy_buffering     off;
    proxy_cache_bypass  $http_upgrade;
  }

  # WebSocket endpoint — upgrade and persist connection
  location /socket.io/ {
    proxy_pass         http://ludo_game_backend;
    proxy_http_version  1.1;

    # CRITICAL: WebSocket upgrade headers
    proxy_set_header   Upgrade     $http_upgrade;
    proxy_set_header   Connection  "upgrade";

    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;

    # Disable buffering — WebSocket frames must flow without delay
    proxy_buffering    off;
    proxy_read_timeout 86400;  # Allow 24hr WebSocket sessions

    # Increase send timeout for high-throughput game servers
    proxy_send_timeout 300;
  }

  # Health check endpoint (no caching)
  location /health {
    proxy_pass         http://ludo_game_backend/health;
    proxy_http_version 1.1;
    proxy_set_header   Host $host;
  }
}
    

Active Health Checks with Nginx Plus or ModSecurity

Open-source Nginx does not natively support active health checks — it marks a server as down only after a failed request, which can strand in-progress Ludo games. Nginx Plus or third-party modules like nginx_upstream_check_module add periodic health probes that proactively remove failed servers from the pool. Alternatively, run a lightweight health endpoint on each game server that Nginx polls every 5 seconds.


// Health check endpoint to attach to your game server
// GET /health — used by load balancer for active probing
app.get('/health', async (req, res) => {
  try {
    // Check Redis connectivity
    await redis.ping();

    // Check PostgreSQL connectivity
    await db.query('SELECT 1');

    // Report active WebSocket connections count
    const activeConnections = wss ? wss.clients.size : 0;
    const maxConnections    = parseInt(process.env.MAX_CONNECTIONS || '1000', 10);

    const health = {
      status:       'healthy',
      uptime:       process.uptime(),
      connections:  activeConnections,
      capacity:     `${Math.round(activeConnections / maxConnections * 100)}%`,
      region:       process.env.REGION || 'unknown',
      version:      process.env.APP_VERSION || 'dev'
    };

    // Warn if approaching capacity
    if (activeConnections >= maxConnections * 0.9) {
      health.status = 'degraded';
      health.reason = 'High connection count';
    }

    res.status(health.status === 'healthy' ? 200 : 503).json(health);

  } catch (err) {
    res.status(503).json({
      status:  'unhealthy',
      error:   err.message,
      uptime:  process.uptime()
    });
  }
});

// Kubernetes readiness probe
app.get('/ready', (req, res) => {
  // Readiness checks: is this server ready to accept NEW connections?
  const isReady = process.uptime() > 10; // Wait 10s after start before marking ready
  res.status(isReady ? 200 : 503).json({ ready: isReady });
});
    

Graceful Shutdown and Zero-Downtime Deployments

When deploying a new game server version, you must migrate existing players off old servers without interrupting their matches. A graceful shutdown sequence waits for active WebSocket sessions to end naturally (up to a timeout), then stops accepting new connections and drains existing ones. Nginx's max_fails and fail_timeout parameters automatically route new players away from the draining server.


// Graceful shutdown handler for Ludo game server
async function gracefulShutdown(signal) {
  console.log(`\n${signal} received. Starting graceful shutdown...`);

  // Step 1: Stop accepting new connections
  server.close(async () => {
    console.log('HTTP server closed (no new connections)');
  });

  // Step 2: Notify all connected players of imminent server restart
  if (wss && wss.clients) {
    wss.clients.forEach(client => {
      if (client.readyState === 1) { // WebSocket.OPEN
        client.send(JSON.stringify({
          type:    'SERVER_SHUTDOWN',
          message: 'Server restarting in 30 seconds. Reconnecting...',
          reconnectIn: 30000
        }));
      }
    });
  }

  // Step 3: Wait for game sessions to complete (max 60 seconds)
  await sleep(60000);

  // Step 4: Force close remaining WebSocket connections
  if (wss && wss.clients) {
    wss.clients.forEach(client => client.terminate());
  }

  // Step 5: Close database and Redis connections
  await db.end();
  await redis.quit();

  console.log('Graceful shutdown complete.');
  process.exit(0);
}

// Set shutdown handlers
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT',  () => gracefulShutdown('SIGINT'));

// Force exit after timeout (safety net)
setTimeout(() => {
  console.error('Forced shutdown after timeout.');
  process.exit(1);
}, 90000);
    

WebSocket Connection Limits and Fairness

Without connection limits, a single IP could saturate a game server's WebSocket capacity, blocking other players. Configure Nginx's limit_conn_zone and per-server connection limits to ensure fair access. For Ludo specifically, limit to one active game per player account to prevent players from creating multiple simultaneous sessions that could be used for unfair practices.

FAQ

Each player's WebSocket connection is bound to a specific game server instance that holds their game state in memory. If a player were routed to a different server mid-match, they would have no game state there and would be unable to participate. Sticky sessions ensure the same server handles all requests from the same client IP throughout the game.

IP hash is the preferred algorithm for WebSocket-based Ludo games because it provides natural sticky sessions without additional configuration. Least connections is an alternative that routes new players to the least-loaded server, but it does not guarantee session affinity.

Nginx passes the Upgrade and Connection headers when configured with proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". This allows the HTTP 101 Switching Protocols response to flow through Nginx to establish the WebSocket tunnel.

With graceful shutdown, the server notifies all players 30 seconds before closing, giving them time to save their game state to Redis and reconnect to a new server. The new server retrieves the persisted state and resumes the match from where it left off.

Configure Nginx limit_conn_zone with a key based on $remote_addr or a authenticated player ID token. Set a reasonable limit (e.g., 5 connections per IP) to prevent abuse while allowing legitimate multi-device usage.

Set Up Production Load Balancing for Your Ludo Platform

Get help configuring Nginx, health checks, and zero-downtime deployments for your Ludo game servers.

Contact Us on WhatsApp