Skip to main content

Command Palette

Search for a command to run...

WebSocket Load Balancing: Sticky Sessions

Published
10 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Traditional Load Balancing Fails for WebSockets

Standard round-robin or least-connections load balancing works well for stateless HTTP requests. Each request is independent, and any backend server can handle it. WebSockets break this model entirely.

When a client establishes a WebSocket connection, the server typically maintains in-memory state: user session data, subscription information, message queues, and application-specific context. If subsequent messages from that client route to a different server instance, that server has no knowledge of the connection state. The result is connection termination, forced re-authentication, or application errors.

The problem intensifies with modern deployment patterns. Kubernetes pods scale up and down based on load. Serverless containers spin up on demand. Auto-scaling groups replace unhealthy instances. Each of these events can disrupt WebSocket connections if your load balancing strategy doesn't account for connection persistence.

In 2025, the challenge extends beyond simple session persistence. Applications now handle:

  • Multi-region deployments where users expect sub-100ms latency regardless of location
  • Hybrid cloud architectures mixing on-premise and cloud infrastructure
  • Edge computing scenarios where WebSocket termination happens at CDN edges
  • Compliance requirements mandating data residency and connection audit trails
  • Cost optimization pressures requiring efficient resource utilization without over-provisioning

Understanding Sticky Sessions for WebSocket Load Balancing

Sticky sessions (also called session affinity) ensure that all requests from a specific client route to the same backend server for the duration of their connection. For WebSocket load balancing, sticky sessions become essential rather than optional.

The load balancer must identify each client and consistently route their traffic to the assigned server instance. This identification happens through several mechanisms:

Source IP-based affinity routes clients based on their IP address. Simple to implement but problematic for clients behind NAT gateways, mobile users switching networks, or corporate proxies serving thousands of users.

Cookie-based affinity embeds a server identifier in an HTTP cookie during the initial handshake. The load balancer reads this cookie on subsequent requests. This approach works well for browser-based WebSocket clients but requires careful cookie management and doesn't help with native mobile or IoT clients.

Connection-based affinity tracks the actual TCP connection and maintains routing state for its lifetime. This provides the most reliable affinity but requires load balancers to maintain connection state, increasing memory requirements at scale.

Modern implementations typically combine multiple strategies with fallback mechanisms to handle edge cases.

Modern Architecture for WebSocket Load Balancing with Sticky Sessions

A production-grade WebSocket load balancing architecture in 2025 requires multiple layers working together. Here's a practical implementation using modern cloud-native patterns.

Layer 4 and Layer 7 Hybrid Approach

The most robust solution uses Layer 4 (TCP) load balancing for initial connection establishment and Layer 7 (application) awareness for intelligent routing decisions.

// WebSocket server with connection tracking
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { Redis } from 'ioredis';

interface ConnectionMetadata {
  userId: string;
  serverId: string;
  connectedAt: number;
  lastActivity: number;
}

class WebSocketLoadBalancedServer {
  private wss: WebSocketServer;
  private redis: Redis;
  private serverId: string;
  private connections: Map<string, ConnectionMetadata>;

  constructor(port: number, serverId: string, redisUrl: string) {
    this.serverId = serverId;
    this.redis = new Redis(redisUrl);
    this.connections = new Map();

    const server = createServer();
    this.wss = new WebSocketServer({ 
      server,
      verifyClient: this.verifyClient.bind(this)
    });

    this.wss.on('connection', this.handleConnection.bind(this));
    server.listen(port);

    // Heartbeat to maintain connection registry
    setInterval(() => this.publishHeartbeat(), 10000);
  }

  private async verifyClient(
    info: { origin: string; secure: boolean; req: any },
    callback: (result: boolean, code?: number, message?: string) => void
  ): Promise<void> {
    const token = new URL(
      info.req.url, 
      `http://${info.req.headers.host}`
    ).searchParams.get('token');

    if (!token) {
      callback(false, 401, 'Missing authentication token');
      return;
    }

    try {
      // Check if user already has an active connection on another server
      const userId = await this.validateToken(token);
      const existingServer = await this.redis.get(`ws:user:${userId}`);

      if (existingServer && existingServer !== this.serverId) {
        // User connected to different server - redirect or reject
        callback(false, 409, 'Connection exists on different server');
        return;
      }

      // Store routing information
      await this.redis.setex(
        `ws:user:${userId}`,
        3600,
        this.serverId
      );

      callback(true);
    } catch (error) {
      callback(false, 403, 'Invalid token');
    }
  }

  private async handleConnection(ws: any, request: any): Promise<void> {
    const url = new URL(request.url, `http://${request.headers.host}`);
    const token = url.searchParams.get('token');
    const userId = await this.validateToken(token!);

    const metadata: ConnectionMetadata = {
      userId,
      serverId: this.serverId,
      connectedAt: Date.now(),
      lastActivity: Date.now()
    };

    this.connections.set(userId, metadata);

    // Set up connection-specific handlers
    ws.on('message', async (data: Buffer) => {
      metadata.lastActivity = Date.now();
      await this.handleMessage(userId, data);
    });

    ws.on('close', () => {
      this.handleDisconnection(userId);
    });

    ws.on('pong', () => {
      metadata.lastActivity = Date.now();
    });

    // Start ping interval for this connection
    const pingInterval = setInterval(() => {
      if (ws.readyState === ws.OPEN) {
        ws.ping();
      } else {
        clearInterval(pingInterval);
      }
    }, 30000);

    // Send connection acknowledgment with server ID
    ws.send(JSON.stringify({
      type: 'connected',
      serverId: this.serverId,
      timestamp: Date.now()
    }));
  }

  private async handleDisconnection(userId: string): Promise<void> {
    this.connections.delete(userId);
    await this.redis.del(`ws:user:${userId}`);
  }

  private async publishHeartbeat(): Promise<void> {
    const activeConnections = Array.from(this.connections.values());

    await this.redis.setex(
      `ws:server:${this.serverId}:heartbeat`,
      30,
      JSON.stringify({
        serverId: this.serverId,
        connectionCount: activeConnections.length,
        timestamp: Date.now()
      })
    );
  }

  private async validateToken(token: string): Promise<string> {
    // Implement your token validation logic
    // Return userId on success, throw on failure
    return 'user-123'; // Placeholder
  }

  private async handleMessage(userId: string, data: Buffer): Promise<void> {
    // Process incoming message
    const message = JSON.parse(data.toString());
    // Your application logic here
  }
}

// Initialize server
const server = new WebSocketLoadBalancedServer(
  8080,
  process.env.SERVER_ID || `server-${process.pid}`,
  process.env.REDIS_URL || 'redis://localhost:6379'
);

Load Balancer Configuration

For AWS Application Load Balancer with sticky sessions:

// Infrastructure as Code using AWS CDK
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';

export class WebSocketLoadBalancerStack {
  constructor(vpc: ec2.Vpc, cluster: ecs.Cluster) {
    const alb = new elbv2.ApplicationLoadBalancer(this, 'WebSocketALB', {
      vpc,
      internetFacing: true,
      http2Enabled: false // Disable HTTP/2 for WebSocket compatibility
    });

    const targetGroup = new elbv2.ApplicationTargetGroup(
      this,
      'WebSocketTargets',
      {
        vpc,
        port: 8080,
        protocol: elbv2.ApplicationProtocol.HTTP,
        targetType: elbv2.TargetType.IP,

        // Enable sticky sessions
        stickinessCookieDuration: Duration.hours(24),
        stickinessCookieName: 'AWSALB',

        // Health check configuration
        healthCheck: {
          path: '/health',
          interval: Duration.seconds(30),
          timeout: Duration.seconds(5),
          healthyThresholdCount: 2,
          unhealthyThresholdCount: 3,
          protocol: elbv2.Protocol.HTTP
        },

        // Deregistration delay for graceful shutdown
        deregistrationDelay: Duration.seconds(60),

        // Connection draining
        targetGroupName: 'websocket-targets'
      }
    );

    // Configure target group attributes for WebSocket
    targetGroup.setAttribute(
      'stickiness.enabled',
      'true'
    );
    targetGroup.setAttribute(
      'stickiness.type',
      'lb_cookie'
    );

    const listener = alb.addListener('WebSocketListener', {
      port: 443,
      protocol: elbv2.ApplicationProtocol.HTTPS,
      certificates: [certificate],
      defaultTargetGroups: [targetGroup]
    });

    // Add listener rule for WebSocket upgrade
    listener.addAction('WebSocketUpgrade', {
      priority: 1,
      conditions: [
        elbv2.ListenerCondition.httpHeader('Upgrade', ['websocket'])
      ],
      action: elbv2.ListenerAction.forward([targetGroup])
    });
  }
}

Handling Connection Migration

When servers scale down or fail, active connections need graceful migration:

// Connection migration handler
class ConnectionMigrationManager {
  private redis: Redis;
  private serverId: string;

  constructor(serverId: string, redisUrl: string) {
    this.serverId = serverId;
    this.redis = new Redis(redisUrl);

    // Listen for server shutdown signals
    process.on('SIGTERM', () => this.gracefulShutdown());
  }

  private async gracefulShutdown(): Promise<void> {
    console.log('Initiating graceful shutdown...');

    // Mark server as draining
    await this.redis.setex(
      `ws:server:${this.serverId}:status`,
      300,
      'draining'
    );

    // Get all active connections
    const connectionKeys = await this.redis.keys(
      `ws:user:*`
    );

    const migratingConnections = [];

    for (const key of connectionKeys) {
      const serverId = await this.redis.get(key);
      if (serverId === this.serverId) {
        const userId = key.replace('ws:user:', '');
        migratingConnections.push(userId);
      }
    }

    // Notify clients to reconnect
    for (const userId of migratingConnections) {
      await this.notifyClientReconnect(userId);
    }

    // Wait for connections to drain
    await this.waitForConnectionDrain(60000);

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

  private async notifyClientReconnect(userId: string): Promise<void> {
    // Publish reconnection message through Redis pub/sub
    await this.redis.publish(
      `ws:reconnect:${userId}`,
      JSON.stringify({
        reason: 'server_shutdown',
        timestamp: Date.now()
      })
    );
  }

  private async waitForConnectionDrain(
    timeoutMs: number
  ): Promise<void> {
    const startTime = Date.now();

    while (Date.now() - startTime < timeoutMs) {
      const heartbeat = await this.redis.get(
        `ws:server:${this.serverId}:heartbeat`
      );

      if (heartbeat) {
        const data = JSON.parse(heartbeat);
        if (data.connectionCount === 0) {
          return;
        }
      }

      await new Promise(resolve => setTimeout(resolve, 1000));
    }

    console.warn('Connection drain timeout reached');
  }
}

Common Pitfalls and Edge Cases

Cookie scope and domain issues: When using cookie-based sticky sessions across subdomains, ensure cookies are set with the correct domain attribute. A cookie set for api.example.com won't be sent to ws.example.com.

Mobile network switching: Mobile clients frequently switch between WiFi and cellular networks, changing their source IP. IP-based affinity breaks immediately. Implement application-level reconnection logic with session token persistence.

Load balancer timeout configurations: Most load balancers have idle timeout settings (typically 60 seconds). WebSocket connections can remain idle for extended periods. Configure timeouts appropriately or implement application-level keepalive pings.

Connection state during deployments: Rolling deployments create a window where some servers run old code and others run new code. If your WebSocket protocol changes, implement version negotiation during the handshake.

Redis single point of failure: Using Redis for connection routing state creates a dependency. Implement Redis Sentinel or Redis Cluster for high availability. Consider fallback mechanisms if Redis becomes unavailable.

Uneven load distribution: Sticky sessions can create hot spots where one server handles disproportionately more long-lived connections. Monitor per-server connection counts and implement connection limits with graceful rejection.

WebSocket subprotocol negotiation: If you use WebSocket subprotocols, ensure your load balancer preserves the Sec-WebSocket-Protocol header during the upgrade handshake.

Best Practices for Production WebSocket Load Balancing

Implement connection limits per server: Prevent any single server from becoming overwhelmed by setting maximum connection thresholds. Return HTTP 503 with Retry-After headers when limits are reached.

Use health checks that verify WebSocket capability: Don't rely solely on HTTP health checks. Implement WebSocket-specific health endpoints that verify the server can accept new connections.

Monitor connection distribution metrics: Track connections per server, connection duration distribution, and reconnection rates. Alert on significant imbalances or reconnection spikes.

Implement exponential backoff for reconnections: Client-side reconnection logic should use exponential backoff with jitter to prevent thundering herd problems during outages.

Design for connection migration: Build your application assuming connections will occasionally need to migrate between servers. Store critical session state in shared storage (Redis, database) rather than purely in-memory.

Set appropriate TTLs for routing state: Balance between memory usage and connection persistence. A 1-hour TTL for routing entries works well for most applications.

Use connection draining during deployments: Configure your orchestration platform (Kubernetes, ECS) to stop sending new connections to pods/containers before terminating them. Allow 60-90 seconds for existing connections to close gracefully.

Implement circuit breakers for backend failures: If a WebSocket server becomes unhealthy, prevent the load balancer from continuing to route new connections to it. Use health check failures to trigger automatic removal from the pool.

Log connection routing decisions: Maintain audit logs of which clients connect to which servers. This proves invaluable for debugging connection issues and understanding traffic patterns.

Test failover scenarios regularly: Regularly simulate server failures, network partitions, and scaling events in staging environments. Verify that clients reconnect successfully and maintain application state.

FAQ

What is the difference between sticky sessions and session affinity for WebSocket load balancing?

These terms are functionally identical. Both refer to routing all traffic from a specific client to the same backend server. "Sticky sessions" is more common in web application contexts, while "session affinity" appears more frequently in networking documentation. For WebSocket load balancing, they describe the same requirement: maintaining consistent routing for the duration of a connection.

How does WebSocket load balancing work with Kubernetes in 2025?

Kubernetes services use iptables or IPVS for load balancing by default, which doesn't provide sticky sessions. For WebSocket load balancing, use an Ingress controller like NGINX Ingress or AWS Load Balancer Controller that supports session affinity. Configure the Ingress resource with annotations like nginx.ingress.kubernetes.io/affinity: "cookie" and set appropriate cookie durations. Alternatively, use a service mesh like Istio with consistent hash-based routing.

What is the best way to handle WebSocket reconnections when sticky sessions fail?

Implement client-side reconnection logic with exponential backoff (starting at 1 second, doubling up to 30 seconds maximum). Include jitter to prevent synchronized reconnection storms. On the server side, design your application to restore session state from shared storage (Redis, database) using a session token passed during reconnection. Send a unique session ID to clients during initial connection that they can present when reconnecting to any server instance.

When should you avoid using sticky sessions for WebSocket load balancing?

Avoid sticky sessions when you can design your application to be fully stateless or when you can replicate all connection state across servers in real-time. This works for simple broadcast scenarios where all clients receive the same messages regardless of which server they connect to. Also consider alternatives if you need perfect load distribution and can afford the complexity of connection state synchronization. For most applications, the operational simplicity of sticky sessions outweighs the minor load distribution inefficiencies.

How do you scale WebSocket connections beyond 100,000 concurrent users?

Beyond 100K concurrent connections, implement a multi-tier architecture. Use a connection layer with lightweight servers handling WebSocket termination and protocol management. Behind this, run an application layer that processes business logic. Store connection routing information in a distributed cache (Redis Cluster). Implement connection pooling between tiers. Use horizontal pod autoscaling based on connection count metrics. Consider regional distribution with GeoDNS routing to reduce latency. Monitor file descriptor limits, TCP buffer sizes, and network bandwidth at scale.

What are the security implications of cookie-based sticky sessions for WebSockets?

Cookie-based affinity cookies should be marked HttpOnly and Secure to prevent XSS attacks from stealing them. However, these cookies typically contain only a server identifier, not sensitive session data. The actual authentication should happen through a separate token passe

WebSocket Load Balancing: Sticky Sessions