system-design55 min read

Load Balancing & Horizontal Scaling: The Complete Guide — A Senior+ Guide | Ayodhyya

Load Balancing & Horizontal Scaling: The Complete Guide

A Deep Dive into Distributing Traffic, Scaling Out Infrastructure, and Building Resilient Systems at Scale

Senior+ System Design Guide 10,000+ Words 16 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why Load Balancing Matters

Every internet-facing application eventually hits a wall: a single server can only handle so many requests before latency spikes, errors multiply, and users leave. Load balancing is the foundational mechanism that distributes incoming network traffic across multiple backend servers so that no single machine becomes a bottleneck. But load balancing is far more than round-robin DNS or a simple reverse proxy. At scale, it becomes a sophisticated traffic management layer that handles SSL termination, request routing, protocol translation, rate limiting, circuit breaking, and real-time health monitoring. Understanding this layer deeply is what separates a senior engineer who can scale a system from one who can only deploy it.

Horizontal scaling — the practice of adding more machines rather than upgrading a single machine — is the primary strategy for building systems that handle millions of concurrent users. Unlike vertical scaling, which has a hard ceiling (the largest machine a cloud provider offers), horizontal scaling is theoretically unbounded. Netflix serves over 200 million subscribers with thousands of microservices running on tens of thousands of instances. Google processes 8.5 billion searches per day across millions of servers. These systems do not run on a single powerful machine; they run on armies of commodity servers coordinated by load balancers, service meshes, and distributed consensus protocols.

The relationship between load balancing and horizontal scaling is symbiotic. Horizontal scaling without load balancing is useless — adding more servers has no effect if traffic still hits the same server. Load balancing without horizontal scaling is limited — a single server behind a load balancer is still a single point of failure. Together, they form the backbone of high-availability, high-performance distributed systems. A load balancer distributes work; horizontal scaling provides the workers. The load balancer detects failures; horizontal scaling replaces failed workers. The load balancer routes traffic intelligently; horizontal scaling ensures enough capacity exists to route to.

Key Insight: Load balancing is not just a networking concern — it is an application architecture decision. The choice of load balancer (L4 vs L7, hardware vs software, client-side vs server-side) fundamentally shapes how you write code, deploy services, handle failures, and observe your system. Choosing wrong at the start creates technical debt that costs exponentially more to fix later.

The history of load balancing traces a fascinating arc through computing. In the 1990s, hardware load balancers like F5 BIG-IP cost $50,000–$500,000 and handled SSL termination with dedicated ASICs. In the 2000s, software load balancers like HAProxy and Nginx democratized load balancing on commodity hardware. In the 2010s, cloud providers offered managed load balancers (AWS ELB, GCP Cloud Load Balancing) that abstracted away the infrastructure entirely. In the 2020s, service meshes (Istio, Linkerd) pushed load balancing into the application layer with sidecar proxies, and edge computing pushed it even closer to users with CDN-integrated load balancing. Each generation reduced operational complexity while increasing flexibility.

Real-world case studies illustrate why load balancing matters. In 2012, Amazon reported that every 100ms of additional latency cost them 1% in sales. Google found that a 0.5-second delay in search results caused a 20% drop in traffic. These numbers make the business case concrete: load balancing is not an optimization — it is a revenue-critical infrastructure component. When GitHub experienced a 24-minute outage in 2018, the root cause was a load balancer misconfiguration that routed all traffic to a single availability zone. The lesson is clear: load balancers are single points of failure if not themselves made highly available.

Scale IndicatorValueImplication for Load Balancing
Netflix peak concurrent streams~50 millionLoad balancer must handle 50M+ concurrent connections
Google queries per second~100,000Sub-millisecond routing decisions at massive scale
Cloudflare DDoS mitigation3.8 Tbps recordLoad balancers absorb volumetric attacks
AWS ALB requests per second1,000,000+Managed LBs handle extreme throughput
Typical enterprise microservice50-500 servicesEach hop needs load balancing decisions

Consider a typical e-commerce platform during Black Friday. Traffic spikes 10x-50x normal levels within minutes. The load balancer must instantaneously distribute millions of requests across hundreds of backend servers, each handling product searches, inventory checks, cart operations, and payment processing. If the load balancer distributes unevenly — sending 80% of traffic to 20% of servers — the overloaded servers crash, triggering cascading failures. If it distributes too aggressively — constantly rebalancing connections — the overhead of connection teardown and setup itself becomes a bottleneck. The art of load balancing is finding the sweet spot between even distribution and minimal overhead.

At its core, load balancing solves four problems: distribution (spreading traffic across capacity), availability (detecting and removing unhealthy servers), scalability (making it easy to add or remove servers), and flexibility (routing traffic based on content, user, or geography). Each of these problems has multiple solutions with different tradeoffs, and the right solution depends on your specific constraints: latency requirements, throughput targets, budget, team expertise, and operational maturity.

2. Types of Load Balancers (L4 vs L7)

Load balancers operate at different layers of the OSI model, and the layer they operate at determines what information they can inspect and how they make routing decisions. The two dominant types are Layer 4 (Transport) and Layer 7 (Application) load balancers. Understanding the difference is critical because it affects performance, flexibility, and the types of routing decisions you can make.

Layer 4 (L4) Load Balancers

Layer 4 load balancers operate at the TCP/UDP transport layer. They see IP addresses and port numbers but do not inspect the contents of HTTP requests. This makes them extremely fast — they can make routing decisions in microseconds because they process minimal information. L4 load balancers typically use NAT (Network Address Translation) or Direct Server Return (DSR) to forward packets to backend servers without terminating the TCP connection. The backend server establishes a direct TCP connection with the client for data transfer.

L4 load balancers excel when you need raw throughput and minimal latency. They are ideal for non-HTTP protocols (databases, game servers, VoIP), SSL/TLS passthrough (where the backend handles SSL termination), and scenarios where you need millions of concurrent connections with sub-millisecond forwarding latency. AWS Network Load Balancer (NLB), HAProxy in TCP mode, and Linux's IPVS are all L4 load balancers. They handle 10-100 million concurrent connections with microsecond-level latency.

C#
// L4 Load Balancer configuration concept
// HAProxy TCP mode configuration
public class L4LoadBalancerConfig
{
    public string Mode => "tcp";
    public int MaxConnections => 10_000_000;
    public TimeSpan TimeoutClient => TimeSpan.FromMinutes(30);
    public TimeSpan TimeoutServer => TimeSpan.FromMinutes(30);
    public BalancerAlgorithm Algorithm => BalancerAlgorithm.LeastConnections;
    public bool UseTcpCheck => true;

    // Backend server pool
    public List<BackendServer> Servers { get; set; } = new()
    {
        new() { Address = "10.0.1.10:5432", Weight = 100 },  // PostgreSQL primary
        new() { Address = "10.0.1.11:5432", Weight = 100 },  // PostgreSQL replica
        new() { Address = "10.0.1.12:5432", Weight = 80 },   // PostgreSQL replica
    };
}

Layer 7 (L7) Load Balancers

Layer 7 load balancers operate at the HTTP/HTTPS application layer. They inspect the full HTTP request — method, headers, cookies, URL path, query parameters, and even request body — before making routing decisions. This inspection capability enables content-based routing: sending API requests to API servers, static file requests to CDN origin servers, WebSocket connections to real-time servers, and video streaming requests to media servers. L7 load balancers terminate SSL/TLS, decrypt the request, inspect it, and establish a new connection to the appropriate backend.

The SSL termination capability is particularly important. Modern web traffic is 95%+ HTTPS. Terminating SSL at the load balancer offloads expensive cryptographic operations from backend servers, centralizes certificate management, and enables the load balancer to inspect decrypted traffic for routing decisions. A single L4 load balancer forwards encrypted TCP packets without understanding them. An L7 load balancer decrypts, inspects, and re-encrypts — giving it full visibility into application traffic.

C#
// L7 Load Balancer configuration concept
// Nginx / HAProxy HTTP mode
public class L7LoadBalancerConfig
{
    public string Mode => "http";
    public bool SslTermination => true;
    public string CertificatePath => "/etc/ssl/certs/app.pem";
    public string KeyPath => "/etc/ssl/private/app.key";

    // Content-based routing rules
    public List<RoutingRule> Rules { get; set; } = new()
    {
        new()
        {
            Match = new() { PathPrefix = "/api/v1", Method = "POST" },
            Backend = "api-servers",
            LoadBalancing = BalancerAlgorithm.RoundRobin
        },
        new()
        {
            Match = new() { PathPrefix = "/api/v1", Method = "GET" },
            Backend = "api-read-servers",
            LoadBalancing = BalancerAlgorithm.LeastConnections
        },
        new()
        {
            Match = new() { Header = "X-Realtime", Value = "true" },
            Backend = "websocket-servers",
            LoadBalancing = BalancerAlgorithm.SourceIpHash
        },
        new()
        {
            Match = new() { PathPrefix = "/static" },
            Backend = "cdn-origin-servers",
            LoadBalancing = BalancerAlgorithm.RoundRobin
        }
    };
}
CharacteristicL4 Load BalancerL7 Load Balancer
OSI LayerTransport (TCP/UDP)Application (HTTP/HTTPS)
Inspection DepthIP + Port onlyFull HTTP request (method, path, headers, body)
SSL HandlingPassthrough (backend terminates)Termination (LB decrypts, inspects, re-encrypts)
Latency Added10-100 microseconds1-10 milliseconds
Throughput10-100 million concurrent connections100K-1M requests/second per instance
Routing FlexibilityIP/port-based onlyURL, header, cookie, method-based
Protocol SupportAny TCP/UDP protocolHTTP, HTTPS, WebSocket, gRPC
Connection ModelTCP passthrough (DSR or NAT)Proxy (terminates and re-establishes)
Best ForDatabase LB, game servers, raw TCPWeb applications, APIs, microservices
ExamplesAWS NLB, HAProxy (TCP), IPVSAWS ALB, Nginx, HAProxy (HTTP), Envoy
graph TB subgraph Clients["Client Requests"] HTTP["HTTPS Requests"] TCP["Raw TCP"] end subgraph L4["Layer 4 LB"] NLB["AWS NLB / HAProxy TCP"] end subgraph L7["Layer 7 LB"] ALB["AWS ALB / Nginx / Envoy"] end subgraph Backends["Backend Pools"] API["API Servers"] WS["WebSocket Servers"] Static["Static Content"] DB["Database"] end HTTP --> ALB TCP --> NLB ALB -->|"Path: /api"| API ALB -->|"Header: realtime"| WS ALB -->|"Path: /static"| Static NLB --> DB

When to Choose L4 vs L7

The decision is not always binary. Many production architectures use both: an L4 load balancer handles initial connection distribution and SSL passthrough, then forwards to L7 load balancers that perform content-based routing. This two-tier approach combines the raw throughput of L4 with the routing flexibility of L7. For example, AWS's recommended architecture for high-throughput applications uses an NLB (L4) in front of multiple ALBs (L7), giving you both millions of concurrent connections and content-based routing.

Another consideration is WebSocket and long-lived connections. L7 load balancers that proxy connections must maintain the full HTTP connection lifecycle, which can be expensive for WebSocket connections that last hours. L4 load balancers, which simply forward TCP packets, handle long-lived connections with minimal overhead. For real-time applications (chat, gaming, live collaboration), an L4 load balancer with source IP hash ensures that a client's WebSocket connection always reaches the same backend without the LB maintaining connection state.

gRPC Load Balancing

gRPC presents unique challenges for load balancing because it uses HTTP/2 multiplexing — a single TCP connection carries multiple concurrent RPC streams. An L4 load balancer cannot distribute these streams across backends because it only sees one TCP connection. The solution is client-side load balancing (via gRPC's built-in load balancing) or L7 load balancing with HTTP/2-aware proxying. Envoy proxy and the gRPC proxy-less load balancing feature in Kubernetes 1.27+ address this by distributing individual RPC calls rather than TCP connections.

C#
// gRPC client-side load balancing in C#
// Uses pick_first (default) vs round_robin
var channel = Channel.ForAddress("dns:///my-service.default.svc.cluster.local:5001", new[]
{
    new SubsystemSwitchHandler("round_robin")
});

// With custom load balancing policy
var options = new GrpcChannelOptions
{
    Credentials = ChannelCredentials.Insecure,
    ServiceConfig = new ServiceConfig
    {
        LoadBalancingConfigs = { new RoundRobinConfig() }
    }
};
var client = new Greeter.GreeterClient(channel);

3. Load Balancing Algorithms

The load balancing algorithm determines how traffic is distributed across backend servers. The choice of algorithm has profound impact on latency, throughput, and resource utilization. There is no universal "best" algorithm — each excels under specific workload patterns. Understanding the tradeoffs is essential for making the right choice.

Round Robin

Round Robin distributes requests sequentially to each server in the pool: Server 1, Server 2, Server 3, Server 1, Server 2, Server 3, and so on. It is the simplest algorithm and works well when all servers have identical capacity and requests have similar resource costs. Its weakness is that it ignores server load — if Server 2 is handling a computationally expensive request while Server 1 is idle, Round Robin still sends the next request to Server 2. Weighted Round Robin addresses this partially by allowing more powerful servers to receive proportionally more requests, but it still does not account for real-time load.

Least Connections

Least Connections routes each request to the server with the fewest active connections. This naturally adapts to varying request costs — a server handling slow requests accumulates more active connections, so fewer new requests are routed to it. The variant Least Response Time goes further by routing to the server with the fewest connections AND the lowest recent response time. This is the most popular algorithm for general-purpose load balancing because it provides the best balance of simplicity and adaptiveness.

IP Hash

IP Hash uses a hash of the client's IP address to consistently route requests from the same client to the same server. This provides session affinity without cookies or session IDs. It works well for stateful applications where sessions are expensive to reconstruct (database connections, in-memory caches). Its weakness is uneven distribution — a large corporate network behind a single NAT IP sends all its traffic to one server. Consistent hashing (covered in Section 9) improves on this by minimizing redistribution when servers are added or removed.

Random

Random selection picks a server uniformly at random for each request. Paradoxically, Random performs similarly to Round Robin for large numbers of requests due to the law of large numbers. However, for small request volumes, Random can produce uneven distributions. It is rarely used as a primary algorithm but is useful as a component in more complex algorithms (like random selection among the top-N least-loaded servers).

AlgorithmHow It WorksProsConsBest For
Round RobinSequential assignment (1,2,3,1,2,3...)Simple, predictable, even distributionIgnores server load and request costUniform workloads, identical servers
Weighted Round RobinProportional assignment based on weightAccounts for heterogeneous serversWeights are static, not adaptiveServers with different capacities
Least ConnectionsRoute to server with fewest active connsAdapts to varying request costsRequires connection count trackingMixed workloads, long-lived connections
Least Response TimeRoute to fastest-responding serverAccounts for real-time performanceRequires latency measurement overheadLatency-sensitive applications
IP HashHash(client IP) determines serverSession affinity without cookiesUneven distribution with NAT usersStateful applications, caching layers
RandomUniform random selectionZero state, no coordinationVariable distribution for small volumesStateless services, micro-batching
Consistent HashHash(request key) on ring topologyMinimal redistribution on scale eventsRequires virtual nodes for balanceCaches, partitioned data stores
Power of Two ChoicesPick 2 random servers, choose least loadedDistributed, near-optimal balanceSlight overhead for load trackingLarge server pools, web backends
C#
public class PowerOfTwoChoicesBalancer<T> where T : class
{
    private readonly T[] _backends;
    private readonly Func<T, int> _getActiveConnections;
    private readonly Random _random = new();

    public PowerOfTwoChoicesBalancer(
        T[] backends, Func<T, int> getActiveConnections)
    {
        _backends = backends;
        _getActiveConnections = getActiveConnections;
    }

    public T NextBackend()
    {
        // Pick two random candidates
        var index1 = _random.Next(_backends.Length);
        var index2 = _random.Next(_backends.Length);

        // Avoid picking the same server twice
        if (index1 == index2)
            index2 = (index2 + 1) % _backends.Length;

        var backend1 = _backends[index1];
        var backend2 = _backends[index2];

        // Choose the one with fewer active connections
        return _getActiveConnections(backend1) <=
               _getActiveConnections(backend2)
            ? backend1
            : backend2;
    }
}

// Usage in a load balancer middleware
public class LoadBalancingMiddleware
{
    private readonly PowerOfTwoChoicesBalancer<BackendServer> _balancer;

    public async Task InvokeAsync(HttpContext context)
    {
        var server = _balancer.NextBackend();
        var connection = server.AcquireConnection();
        try
        {
            await connection.ForwardRequestAsync(context.Request);
        }
        finally
        {
            server.ReleaseConnection(connection);
        }
    }
}
Power of Two Choices: This algorithm, analyzed in a famous MIT paper, provides 95% of the balance of "perfect" least-connections with only 2 random probes instead of scanning all N servers. It is used by Netflix's Zuul, Facebook's Thrift, and many high-performance load balancers. The key insight: picking 2 random servers and choosing the less loaded one reduces maximum load by a factor of ln(ln(N)) compared to pure random selection.

Algorithm Selection Decision Framework

Choosing the right algorithm requires understanding your workload characteristics. For stateless REST APIs with uniform request costs, Weighted Round Robin is sufficient and simple. For WebSocket connections with variable durations, Least Connections adapts naturally. For caching layers where cache locality matters, Consistent Hashing minimizes cache misses on scale events. For large-scale web backends where scanning all servers is expensive, Power of Two Choices provides near-optimal balance with O(1) overhead. The decision should be driven by data: measure your request latency distribution, server utilization variance, and session dependency before choosing.

C#
// Load balancer factory — choose algorithm based on workload
public static class LoadBalancerFactory
{
    public static ILoadBalancer Create(LoadBalancerOptions options)
    {
        return options.Algorithm switch
        {
            Algorithm.RoundRobin => new RoundRobinBalancer(options.Backends),
            Algorithm.WeightedRoundRobin => new WeightedRoundRobinBalancer(
                options.Backends, options.Weights),
            Algorithm.LeastConnections => new LeastConnectionsBalancer(
                options.Backends, options.ConnectionTracker),
            Algorithm.IpHash => new IpHashBalancer(
                options.Backends, options.HashSeed),
            Algorithm.PowerOfTwoChoices => new PowerOfTwoChoicesBalancer(
                options.Backends, options.LoadMetrics),
            Algorithm.LeastResponseTime => new LeastResponseTimeBalancer(
                options.Backends, options.LatencyTracker),
            _ => throw new ArgumentException(
                $"Unknown algorithm: {options.Algorithm}")
        };
    }
}

4. Health Checks & Failover

A load balancer that distributes traffic to unhealthy servers is worse than no load balancer at all — it actively degrades performance by sending requests to servers that will fail or timeout. Health checks are the mechanism by which a load balancer continuously verifies that each backend server is capable of handling requests. Without health checks, load balancing is just traffic distribution. With health checks, it becomes traffic management with self-healing capability.

Types of Health Checks

Health checks exist on a spectrum from lightweight to comprehensive. TCP health checks simply verify that a TCP connection can be established to the server's port. This catches crashed processes and network partitions but not application-level failures (a process can accept TCP connections but return HTTP 500 for every request). HTTP health checks send an actual HTTP request to a designated health endpoint and verify the response status code. This catches application-level failures but requires a dedicated health endpoint that validates the application's ability to serve requests. Deep health checks verify that the application can perform its core function — querying a database, reading from a cache, processing a test payload. This catches the most subtle failures but adds overhead and complexity.

C#
// Health check service with multiple probe types
public class HealthCheckService : BackgroundService
{
    private readonly IServiceHealthProbe _probe;
    private readonly ILoadBalancerState _lbState;
    private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5);
    private readonly TimeSpan _unhealthyThreshold = TimeSpan.FromSeconds(15);
    private readonly TimeSpan _healthyThreshold = TimeSpan.FromSeconds(10);
    private readonly Dictionary<string, ServerHealthState> _states = new();

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            foreach (var server in _lbState.GetServers())
            {
                var previousState = _states.GetValueOrDefault(
                    server.Id, new ServerHealthState());
                var isHealthy = await _probe.CheckHealthAsync(server);

                var newState = previousState with
                {
                    ConsecutiveHealthy = isHealthy
                        ? previousState.ConsecutiveHealthy + 1 : 0,
                    ConsecutiveUnhealthy = !isHealthy
                        ? previousState.ConsecutiveUnhealthy + 1 : 0,
                    LastChecked = DateTime.UtcNow
                };

                // Transition to unhealthy after threshold
                if (previousState.Status == ServerStatus.Healthy &&
                    newState.ConsecutiveUnhealthy * _checkInterval >=
                    _unhealthyThreshold)
                {
                    newState.Status = ServerStatus.Unhealthy;
                    _lbState.MarkUnhealthy(server.Id);
                    LogUnhealthy(server, newState);
                }

                // Transition back to healthy after threshold
                if (previousState.Status == ServerStatus.Unhealthy &&
                    newState.ConsecutiveHealthy * _checkInterval >=
                    _healthyThreshold)
                {
                    newState.Status = ServerStatus.Healthy;
                    _lbState.MarkHealthy(server.Id);
                    LogRecovered(server, newState);
                }

                _states[server.Id] = newState;
            }

            await Task.Delay(_checkInterval, ct);
        }
    }
}

public record ServerHealthState
{
    public ServerStatus Status { get; init; } = ServerStatus.Healthy;
    public int ConsecutiveHealthy { get; init; }
    public int ConsecutiveUnhealthy { get; init; }
    public DateTime LastChecked { get; init; }
}

public enum ServerStatus { Healthy, Unhealthy, Draining }

Health Check Timing

The timing of health checks involves a critical tradeoff: checking too frequently adds load to servers and the load balancer itself, while checking too infrequently delays failure detection. AWS ALB defaults to 30-second intervals with a 2-failure threshold — detecting failure in 60 seconds. For latency-sensitive applications, 5-second intervals with a 3-failure threshold (15-second detection) are common. The healthy threshold should be longer than the unhealthy threshold to prevent flapping — rapid oscillation between healthy and unhealthy states that causes traffic instability.

Check TypeWhat It VerifiesLatency OverheadCatchesBest For
TCP ConnectPort accepts connections< 1msProcess crash, network partitionNon-HTTP services (DB, Redis)
HTTP 200Health endpoint returns 2001-10msApplication crash, OOM, deadlockWeb services, REST APIs
HTTP + SchemaResponse matches expected schema5-50msData corruption, dependency failureAPI gateways, BFF pattern
Deep CheckFull functional verification50-500msDatabase connectivity, cache hit rateStateful services, data pipelines

Failover Mechanisms

When a health check fails, the load balancer must remove the server from the routing pool without disrupting in-flight requests. This requires a "draining" state: the server stops receiving new connections but continues serving existing ones until they complete or timeout. Draining prevents the abrupt termination of requests, which causes user-visible errors. The draining timeout is typically 30-300 seconds depending on the expected request duration.

C#
public class ServerDrainManager
{
    private readonly TimeSpan _drainTimeout = TimeSpan.FromSeconds(60);
    private readonly ConcurrentDictionary<string, DrainState> _draining = new();

    public async Task StartDrainingAsync(string serverId)
    {
        var state = new DrainState
        {
            StartedAt = DateTime.UtcNow,
            ActiveConnections = GetActiveConnectionCount(serverId)
        };
        _draining[serverId] = state;

        // Remove from active pool immediately (no new connections)
        await RemoveFromPoolAsync(serverId);

        // Wait for existing connections to complete
        var deadline = DateTime.UtcNow.Add(_drainTimeout);
        while (DateTime.UtcNow < deadline)
        {
            var remaining = GetActiveConnectionCount(serverId);
            if (remaining == 0)
            {
                Console.WriteLine(
                    $"Server {serverId} drained gracefully " +
                    $"({state.ActiveConnections} connections " +
                    $"completed)");
                _draining.TryRemove(serverId, out _);
                return;
            }
            await Task.Delay(TimeSpan.FromSeconds(5));
        }

        // Drain timeout exceeded — force disconnect remaining
        var forced = GetActiveConnectionCount(serverId);
        await ForceDisconnectAsync(serverId);
        Console.WriteLine(
            $"Server {serverId} drain timeout — " +
            $"force-closed {forced} connections");
    }
}
Passive Health Checks: Beyond active probes, load balancers can detect failures passively by observing real request outcomes. If a server returns three consecutive HTTP 5xx errors, the load balancer can automatically mark it unhealthy without waiting for the next active health check. This "circuit breaker" approach detects failures in real-time rather than at the next check interval. However, it must be combined with active checks — passive checks alone can declare a server unhealthy due to a single bad request (a legitimate 404), while active checks verify the server's overall health.

5. Session Affinity & Sticky Sessions

Session affinity (also called sticky sessions) ensures that all requests from a particular client are routed to the same backend server. This is necessary when the server maintains client-specific state in memory: shopping carts, user preferences, WebSocket connections, or in-memory session data. Without affinity, a user's request might land on Server A, which stores their cart, then their next request lands on Server B, which has no knowledge of the cart — the user sees an empty cart.

Affinity Mechanisms

There are three primary mechanisms for implementing session affinity. Cookie-based affinity embeds a cookie in the HTTP response that identifies the target server. On subsequent requests, the load balancer reads the cookie and routes to the same server. This is the most common approach for HTTP applications. IP-based affinity hashes the client IP to determine the target server. This works for non-HTTP protocols but fails behind NAT (many clients sharing one IP). Header-based affinity uses a custom header (like X-Session-ID) to identify the session, giving the application control over affinity.

C#
// Session affinity middleware using consistent hashing
public class SessionAffinityMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConsistentHashRing<Server> _hashRing;

    public SessionAffinityMiddleware(
        RequestDelegate next, IConsistentHashRing<Server> hashRing)
    {
        _next = next;
        _hashRing = hashRing;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        string sessionKey = GetSessionKey(context);
        var server = _hashRing.GetNode(sessionKey);

        // Forward to the designated server
        context.Items["TargetServer"] = server;
        await _next(context);
    }

    private string GetSessionKey(HttpContext context)
    {
        // Try session cookie first
        if (context.Request.Cookies.TryGetValue("LB_SESSION", out var cookie))
            return cookie;

        // Fall back to client IP hash
        var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return $"ip:{ip}";
    }
}

// Nginx-style cookie-based affinity configuration
public class StickySessionConfig
{
    public string CookieName { get; set; } = "SERVERID";
    public string CookiePath { get; set; } = "/";
    public TimeSpan CookieExpiry { get; set; } = TimeSpan.FromHours(24);
    public bool HttpOnly { get; set; } = true;
    public bool SecureOnly { get; set; } = true;
    public string HashMethod { get; set; } = "sha256";
}

The Anti-Pattern: Over-Reliance on Sticky Sessions

Session affinity is often a design smell. If your application truly requires all requests from a client to hit the same server, you have tightly coupled state to a specific machine, which makes scaling, failover, and deployment harder. When the affinity target crashes, the user loses their session entirely. When you deploy a new version, rolling updates require complex session migration. The better pattern is externalized session state: store sessions in Redis, Memcached, or a distributed cache. Any server can serve any request because session data is not in-memory. This eliminates affinity entirely, simplifying load balancing and improving resilience.

ApproachSession StorageScalabilityFailoverComplexity
In-memory + Sticky SessionsServer RAMLimited (one server per session)Session lost on server failureLow
Externalized (Redis)Redis clusterUnlimited (any server, any session)Session survives server failureMedium
JWT (stateless)Client (token)UnlimitedFully resilientMedium
Database-backedPostgreSQL/MySQLUnlimitedFully resilientHigh
Modern Best Practice: Externalize all server-side state to Redis or a distributed cache. Use JWT tokens (which contain session data in the token itself) or session IDs that reference external storage. This eliminates the need for sticky sessions entirely, allowing any load balancer to route any request to any server. Netflix, Uber, and Airbnb all use stateless microservices with externalized state — sticky sessions are an anti-pattern at scale.

6. Horizontal Scaling Fundamentals

Horizontal scaling is the practice of adding more machines to a pool of resources rather than upgrading the capacity of a single machine. This approach is the foundation of cloud computing, microservices architectures, and any system that needs to handle unpredictable or massive workloads. The core principle is simple: if one machine handles 1,000 requests per second, ten machines should handle 10,000. In practice, the reality is more nuanced due to shared state, coordination overhead, and non-distributed workloads.

Stateless vs Stateful Services

The first and most important distinction in horizontal scaling is between stateless and stateful services. Stateless services (API servers, compute workers, image processors) can be scaled horizontally with zero coordination — just add more instances behind a load balancer. Every request is self-contained; it carries all the information the server needs to process it. No server remembers anything about previous requests. This makes horizontal scaling trivial: deploy 100 instances, put a load balancer in front, done.

Stateful services (databases, caches, message queues) are harder to scale horizontally because they maintain state that must be consistent across instances. A PostgreSQL database cannot simply be "duplicated" — the two copies would have different data. Scaling stateful services requires techniques like replication (one primary, multiple replicas), sharding (splitting data across multiple primaries), and consensus protocols (Raft/Paxos for distributed agreement). These techniques are covered in depth in Section 8.

C#
// Stateless service — horizontal scaling is trivial
// Each instance is identical and handles any request
public class StatelessOrderService : IOrderService
{
    private readonly IOrderRepository _repository; // External DB
    private readonly IPaymentGateway _payment;     // External API
    private readonly IEventPublisher _events;      // External queue

    public async Task<OrderResult> CreateOrderAsync(
        CreateOrderRequest request)
    {
        // All state lives in external services
        // This instance holds zero in-memory state
        var order = Order.Create(
            request.CustomerId,
            request.Items,
            request.PaymentMethod);

        await _repository.SaveAsync(order);
        await _payment.ChargeAsync(order.TotalAmount);
        await _events.PublishAsync(new OrderCreatedEvent(order.Id));

        return new OrderResult(order.Id, "Created");
    }
}

// Stateful service — horizontal scaling requires coordination
public class StatefulShoppingCartService
{
    // BAD: In-memory state prevents horizontal scaling
    private readonly ConcurrentDictionary<string, Cart> _carts = new();

    // GOOD: Redis-backed state enables horizontal scaling
    private readonly IDistributedCache _cache;

    public async Task<Cart> GetCartAsync(string userId)
    {
        var data = await _cache.GetAsync($"cart:{userId}");
        return data != null
            ? JsonSerializer.Deserialize<Cart>(data)
            : new Cart(userId);
    }
}

The CAP Theorem and Horizontal Scaling

Horizontal scaling of stateful services must contend with the CAP theorem: in a network partition, you must choose between Consistency and Availability. A single PostgreSQL instance gives you both (no partition), but it does not scale. A PostgreSQL primary with read replicas gives you consistency (writes go to primary) but availability suffers if the primary dies. A Cassandra cluster with replication factor 3 gives you availability (any node can serve reads) but eventual consistency. The choice depends on your consistency requirements: banking systems choose consistency; social media feeds choose availability.

Service TypeState LocationScaling DifficultyTechnique
Web API (REST/gRPC)External (DB, cache)Trivial — add instancesLoad balancer + auto-scaling group
Background WorkerMessage queueTrivial — add consumersConsumer group scaling
Cache (Redis)In-memory (distributed)Moderate — hash slotsRedis Cluster with 16,384 hash slots
Database (PostgreSQL)Disk (replicated)Hard — replication lagRead replicas + connection pooling
Database (Cassandra)Disk (distributed)Moderate — consistent hashRing topology with replication
Message Queue (Kafka)Disk (partitioned)Moderate — partition rebalanceTopic partitioning + consumer groups

The Six-Server Problem

A common misconception is that six servers provide 6x the throughput of one server. In reality, overhead from coordination, network communication, and data synchronization reduces the effective multiplier. For purely stateless HTTP processing, you might achieve 5.5x throughput (5 servers × 110%). For stateful distributed systems with strong consistency, you might achieve 3-4x throughput due to consensus overhead. The N+1 problem (adding N servers gives you N-1 effective capacity due to redundancy) means you always need at least one more server than you think. Understanding these diminishing returns is critical for capacity planning.

graph LR subgraph Vertical["Vertical Scaling"] V1["Single Server
CPU: 96 cores
RAM: 384GB
$8,000/mo"] end subgraph Horizontal["Horizontal Scaling"] H1["8x Small Servers
CPU: 12 cores each
RAM: 48GB each
$2,400/mo total"] end subgraph Distributed["Distributed Trade-offs"] COORD["Coordination Overhead"] NET["Network Latency"] CONSIST["Consistency Cost"] end Vertical -->|"Ceiling Hit"| Horizontal Horizontal -->|"Complexity"| Distributed

7. Auto-Scaling Strategies

Auto-scaling is the automatic adjustment of computing resources based on current demand. Without auto-scaling, you must either over-provision (wasting money on idle capacity) or under-provision (suffering outages during traffic spikes). Auto-scaling finds the optimal point: scale up when demand increases, scale down when demand decreases, always maintaining sufficient capacity with minimal waste.

Scaling Policies

There are three primary auto-scaling policies, each with distinct tradeoffs. Reactive scaling responds to current metrics: if CPU exceeds 70%, add instances; if CPU drops below 30%, remove instances. This is the simplest approach but has a critical flaw — it reacts to problems after they occur. By the time CPU hits 70%, your servers are already degraded. Predictive scaling uses historical patterns to pre-provision capacity: if traffic spikes every weekday at 9 AM, pre-scale at 8:45 AM. This eliminates the lag of reactive scaling but fails when traffic patterns change unexpectedly. Hybrid scaling combines both: predictive scaling handles known patterns, reactive scaling handles surprises.

C#
public class AutoScaler : BackgroundService
{
    private readonly IMetricsCollector _metrics;
    private readonly IComputeProvider _compute;
    private readonly AutoScaleConfig _config;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var metrics = await _metrics.GetCurrentMetricsAsync();
            var currentInstances = await _compute.GetInstanceCountAsync();
            var desiredInstances = CalculateDesiredInstances(
                metrics, currentInstances);

            if (desiredInstances > currentInstances)
            {
                var toAdd = Math.Min(
                    desiredInstances - currentInstances,
                    _config.MaxScaleUpStep);
                Console.WriteLine(
                    $"Scaling UP: {currentInstances} → " +
                    $"{currentInstances + toAdd} " +
                    $"(CPU: {metrics.AvgCpuPercent:F1}%, " +
                    $"Queue: {metrics.QueueDepth})");
                await _compute.ScaleUpAsync(toAdd);
            }
            else if (desiredInstances < currentInstances)
            {
                var toRemove = Math.Min(
                    currentInstances - desiredInstances,
                    _config.MaxScaleDownStep);
                Console.WriteLine(
                    $"Scaling DOWN: {currentInstances} → " +
                    $"{currentInstances - toRemove}");
                await _compute.ScaleDownAsync(toRemove);
            }

            await Task.Delay(_config.EvaluationInterval, ct);
        }
    }

    private int CalculateDesiredInstances(
        SystemMetrics metrics, int current)
    {
        double targetUtilization = _config.TargetCpuPercent;

        // Base scaling: maintain target CPU utilization
        int baseCount = (int)Math.Ceiling(
            metrics.TotalCpuCores /
            (targetUtilization / 100.0 * _config.CoresPerInstance));

        // Queue-based scaling: ensure queue depth per instance is bounded
        int queueCount = (int)Math.Ceiling(
            (double)metrics.QueueDepth /
            _config.MaxQueueDepthPerInstance);

        // Take the maximum to satisfy both constraints
        int desired = Math.Max(baseCount, queueCount);

        // Apply bounds
        desired = Math.Max(_config.MinInstances, desired);
        desired = Math.Min(_config.MaxInstances, desired);

        return desired;
    }
}

Cool-Down Periods

A critical but often overlooked aspect of auto-scaling is the cool-down period — the minimum time between scaling actions. Without cool-down, auto-scalers can oscillate: scale up because of a spike, then immediately scale down because the spike resolved, then scale up again because another spike arrives. This "flapping" wastes resources and destabilizes the system. Typical cool-down periods are 5-10 minutes for scale-up and 15-30 minutes for scale-down. Scale-down cool-downs should be longer because the cost of scaling down too aggressively (outage) is higher than the cost of scaling down too slowly (wasted money).

Scaling MetricScale-Up ThresholdScale-Down ThresholdCool-Down
CPU Utilization> 70% for 3 minutes< 30% for 10 minutes5 min up / 15 min down
Memory Utilization> 80% for 3 minutes< 40% for 10 minutes5 min up / 15 min down
Request Queue Depth> 100 per instance< 10 per instance for 10 min2 min up / 10 min down
Response Latency (P95)> 500ms for 3 minutes< 100ms for 10 minutes5 min up / 20 min down
Custom (business metric)Orders/min > capacityOrders/min < 50% capacity3 min up / 10 min down
The Cold Start Problem: Auto-scaling takes time. New instances must boot (30s-5min), download code, connect to databases, warm caches, and pass health checks before receiving traffic. During this startup period, the system is under-provisioned and vulnerable to overload. Mitigations include: pre-provisioning warm instances (keeping idle capacity during known peak periods), using container snapshots (Firecracker, Fly.io) for sub-second cold starts, and implementing request queuing with backpressure at the load balancer level.

8. Database Scaling (Read Replicas, Sharding)

Databases are the hardest component to scale horizontally. While application servers can be duplicated freely, databases hold unique data that must remain consistent and available. Database scaling strategies fall into three categories: read scaling (replication), write scaling (sharding), and hybrid approaches (read replicas + sharding).

Read Replicas

Read replicas create copies of the primary database that accept read queries but not writes. The primary replicates its data to replicas using asynchronous or semi-synchronous replication. Reads are distributed across replicas using a load balancer, while all writes go to the primary. This is effective when reads significantly outnumber writes (80/20 or 90/10 read/write ratio, which is typical for most applications).

C#
// Read/write splitting with connection routing
public class DatabaseRouter : IDbRouter
{
    private readonly IDbConnection _primary;
    private readonly IDbConnection[] _replicas;
    private readonly ILoadBalancer _readBalancer;
    private readonly ITransactionContext _txContext;

    public IDbConnection GetConnection(OperationType type)
    {
        // If we're in an explicit transaction, always use primary
        if (_txContext.InTransaction)
            return _primary;

        return type switch
        {
            OperationType.Read => _readBalancer.NextBackend(),
            OperationType.Write => _primary,
            OperationType.ReadAfterWrite => _primary,  // Consistency!
            _ => _primary
        };
    }
}

// Transaction-aware routing
public class TransactionAwareDbProxy
{
    private readonly DatabaseRouter _router;

    public async Task<T> ExecuteInTransactionAsync<T>(
        Func<IDbConnection, Task<T>> action)
    {
        // Transaction opens on primary, all reads/writes within
        // the transaction go to primary for consistency
        using var transaction = await _router.BeginTransactionAsync();
        try
        {
            var result = await action(_router.PrimaryConnection);
            await transaction.CommitAsync();
            return result;
        }
        catch
        {
            await transaction.RollbackAsync();
            throw;
        }
    }
}

Database Sharding

Sharding splits a database horizontally across multiple servers, each holding a subset of the data. A shard key determines which shard stores each row. For example, an orders table sharded by customer_id modulo 4 places orders from customer 1001 on shard 1, customer 1002 on shard 2, and so on. Sharding allows write capacity to scale linearly — each shard handles 1/N of the total writes.

The challenge with sharding is cross-shard queries. If you need to join data across shards or run aggregate queries (SUM, COUNT, AVG) across the entire dataset, you must scatter-gather: send the query to all shards, collect results, and merge. This is slow and expensive. The shard key must be chosen carefully to minimize cross-shard queries. Common shard keys include user_id, tenant_id, or region — keys that align with natural query boundaries.

C#
public class ShardRouter
{
    private readonly IShardMap _shardMap;
    private readonly IDbConnection[] _shards;

    public IDbConnection GetShard(string shardKey)
    {
        var shardIndex = _shardMap.GetShardIndex(shardKey);
        return _shards[shardIndex];
    }
}

// Consistent hashing-based sharding
public class ConsistentHashShardMap : IShardMap
{
    private readonly ConsistentHashRing<int> _ring;
    private readonly int _virtualNodesPerShard = 150;

    public ConsistentHashShardMap(int shardCount)
    {
        _ring = new ConsistentHashRing<int>();
        for (int i = 0; i < shardCount; i++)
        {
            for (int v = 0; v < _virtualNodesPerShard; v++)
            {
                _ring.AddNode(i, $"shard-{i}-vnode-{v}");
            }
        }
    }

    public int GetShardIndex(string key)
    {
        return _ring.GetNode(key);
    }
}
StrategyScalesCross-Shard QueriesComplexityBest For
Read ReplicasRead throughputN/A (single primary)LowRead-heavy apps (blogs, catalogs)
Vertical PartitioningTable-level writesJOINs across partitionsMediumWide tables with independent column groups
Hash-based ShardingWrite throughputScatter-gather (expensive)HighUniformly distributed write workloads
Range-based ShardingWrite throughputPartial scatter (adjacent ranges)HighTime-series data, log storage
Directory-based ShardingWrite throughputDepends on key localityVery HighMulti-tenant SaaS with tenant routing
Sharding Pitfall: Hot spots occur when one shard receives disproportionate traffic. For example, sharding by customer_id with a celebrity customer who has 100x more orders than average creates a hot shard. Mitigations include: using consistent hashing with many virtual nodes (spreads hot spots), detecting hot shards through monitoring and splitting them, and pre-splitting shards at creation time rather than relying on natural distribution.

9. Consistent Hashing

Consistent hashing is a critical algorithm for distributed systems that need to map keys to servers while minimizing disruption when servers are added or removed. Naive modulo hashing (server = hash(key) % N) breaks when N changes — adding one server remaps nearly every key. Consistent hashing uses a ring topology where both keys and servers map to positions on the ring, and each key is assigned to the next server clockwise. When a server is added or removed, only the keys in its immediate neighborhood are remapped — typically 1/N of all keys.

The Ring Algorithm

Imagine a circle (ring) labeled 0 to 2^32-1. Servers are placed at positions on the ring by hashing their address. Keys are placed at positions by hashing the key. Each key is assigned to the first server found by walking clockwise from the key's position. When a server is removed, its keys naturally flow to the next clockwise server. When a server is added, it takes keys from the next clockwise server. The beauty is that only local keys are affected — the rest of the ring is unchanged.

C#
public class ConsistentHashRing<T> where T : class
{
    private readonly SortedDictionary<uint, T> _ring = new();
    private readonly int _virtualNodesPerServer;
    private readonly Func<string, uint> _hashFunction;

    public ConsistentHashRing(
        int virtualNodesPerServer = 150,
        Func<string, uint>? hashFunction = null)
    {
        _virtualNodesPerServer = virtualNodesPerServer;
        _hashFunction = hashFunction ?? Fnv1aHash;
    }

    public void AddServer(T server, string serverKey)
    {
        for (int i = 0; i < _virtualNodesPerServer; i++)
        {
            var vnodeKey = $"{serverKey}:vnode:{i}";
            var hash = _hashFunction(vnodeKey);
            _ring[hash] = server;
        }
    }

    public void RemoveServer(string serverKey)
    {
        for (int i = 0; i < _virtualNodesPerServer; i++)
        {
            var vnodeKey = $"{serverKey}:vnode:{i}";
            var hash = _hashFunction(vnodeKey);
            _ring.Remove(hash);
        }
    }

    public T GetNode(string key)
    {
        if (_ring.Count == 0)
            throw new InvalidOperationException("Ring is empty");

        var hash = _hashFunction(key);

        // Find the first server clockwise from the key's position
        foreach (var kvp in _ring)
        {
            if (kvp.Key >= hash)
                return kvp.Value;
        }

        // Wrap around to the first server on the ring
        return _ring.First().Value;
    }

    // FNV-1a hash for fast, well-distributed hashing
    private static uint Fnv1aHash(string input)
    {
        const uint FNV_OFFSET = 2166136261;
        const uint FNV_PRIME = 16777619;
        uint hash = FNV_OFFSET;
        foreach (byte b in System.Text.Encoding.UTF8.GetBytes(input))
        {
            hash ^= b;
            hash *= FNV_PRIME;
        }
        return hash;
    }
}

Virtual Nodes

Without virtual nodes, consistent hashing produces uneven distributions — some servers get 3x more keys than others. Virtual nodes solve this by placing each physical server at multiple positions on the ring (typically 100-200 virtual nodes per server). This averages out the distribution, achieving near-uniform assignment with less than 5% variance between servers. Netflix uses 150 virtual nodes per server; Amazon DynamoDB uses 256.

graph TB subgraph Ring["Consistent Hash Ring (Simplified)"] direction TB V0["0"] --> V1["1"] V1 --> V2["2"] V2 --> V3["3"] V3 --> V4["4"] V4 --> V5["5"] V5 --> V6["6"] V6 --> V7["7"] V7 --> V0 end S1["Server A
Hash: 1, 3, 6"] -.-> Ring S2["Server B
Hash: 2, 5"] -.-> Ring S3["Server C
Hash: 4, 7"] -.-> Ring

Real-World Applications

Consistent hashing is used everywhere: Redis Cluster maps 16,384 hash slots across nodes using consistent hashing. Cassandra distributes data across the ring topology using consistent hashing with virtual nodes. Amazon DynamoDB uses consistent hashing for partition management. CDNs use consistent hashing to assign URLs to edge servers. Kafka uses consistent hashing for partition assignment across brokers. In each case, the algorithm ensures that adding or removing a node causes minimal data movement.

SystemVirtual NodesHash FunctionKey Use Case
Redis Cluster16,384 slots (fixed)CRC16Data partitioning across nodes
Cassandra256 per nodeMurmur3Data distribution across ring
Amazon DynamoDBDynamicMD5-derivedPartition key routing
Netflix Zuul150 per serverFnv1aRequest routing to microservices
MemcachedNone (modulo)CRC32Key-to-server mapping (no virtual nodes)
Practical Tip: When implementing consistent hashing, always use virtual nodes. Without them, a 4-node cluster might have one server handling 35% of keys and another handling 15%. With 150 virtual nodes per server, all servers handle 25% ± 2%. The virtual node count is a tunable parameter — more virtual nodes means better balance but more memory for the ring lookup table. 150 is the sweet spot used by Netflix and most production systems.

10. Service Discovery

In a dynamic environment where instances are constantly created and destroyed (Kubernetes pods, auto-scaled instances, blue-green deployments), clients need a way to find the current set of healthy service instances. Service discovery replaces static IP configuration with dynamic resolution: instead of hardcoding server addresses, clients ask a discovery service "where is Service X right now?" and get back a list of healthy endpoints.

Discovery Patterns

There are three primary service discovery patterns. Client-side discovery puts the discovery logic in the client: the client queries a service registry (Consul, etcd, ZooKeeper) and load-balances across the returned endpoints itself. This eliminates an extra network hop but couples client code to the discovery mechanism. Server-side discovery** uses a load balancer or proxy in front of services: the client sends requests to a well-known address (the load balancer), and the load balancer queries the registry and routes to a healthy instance. This is simpler for clients but adds a network hop. DNS-based discovery** maps service names to DNS records that are dynamically updated as instances change. Kubernetes Services use this pattern — a Service name resolves to a ClusterIP that is load-balanced across pods.

C#
// Client-side service discovery with health-aware routing
public class ServiceDiscoveryClient
{
    private readonly IServiceRegistry _registry;
    private readonly ILoadBalancer _balancer;
    private readonly Timer _refreshTimer;

    public ServiceDiscoveryClient(IServiceRegistry registry)
    {
        _registry = registry;
        _balancer = new LeastConnectionsBalancer();

        // Refresh service endpoints every 10 seconds
        _refreshTimer = new Timer(
            async _ => await RefreshEndpointsAsync(),
            null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    private async Task RefreshEndpointsAsync()
    {
        var instances = await _registry.GetHealthyInstancesAsync(
            "order-service");
        _balancer.UpdateBackends(instances);
    }

    public ServiceEndpoint GetEndpoint()
    {
        return _balancer.NextBackend();
    }
}

// Service registry (Consul-style)
public interface IServiceRegistry
{
    Task RegisterAsync(ServiceRegistration registration);
    Task DeregisterAsync(string instanceId);
    Task<IReadOnlyList<ServiceInstance>>
        GetHealthyInstancesAsync(string serviceName);
    Task<IReadOnlyList<ServiceInstance>>
        GetAllInstancesAsync(string serviceName);
}

public record ServiceInstance(
    string Id,
    string Host,
    int Port,
    Dictionary<string, string> Metadata,
    DateTime LastHeartbeat);

public record ServiceRegistration(
    string ServiceName,
    string InstanceId,
    string Host,
    int Port,
    TimeSpan Ttl);

Kubernetes Service Discovery

In Kubernetes, service discovery is built into the platform. A Service object creates a stable DNS name and ClusterIP that load-balances across matching Pods. As Pods are created and destroyed by deployments, the Service automatically updates its endpoint list. This is server-side discovery handled by kube-proxy, which programs iptables or IPVS rules to distribute traffic across Pod IPs. The client simply connects to the Service DNS name; the network layer handles routing.

PatternClient ComplexityExtra HopExamples
Client-SideHigh (client does LB)NoNetflix Ribbon, gRPC client-side LB
Server-SideLow (just connect to LB)YesAWS ALB, Kubernetes Service
DNS-BasedLow (DNS resolution)Yes (DNS + LB)Kubernetes DNS, Consul DNS
Service MeshZero (sidecar handles it)Yes (sidecar proxy)Istio, Linkerd, Consul Connect
Service Mesh Evolution: Service meshes (Istio, Linkerd) push service discovery and load balancing into the data plane via sidecar proxies (Envoy). Each service instance gets a proxy that handles service discovery, load balancing, circuit breaking, retries, and observability — all transparently. The application code has zero knowledge of these concerns. This is the most advanced form of service discovery but adds latency (2-5ms per hop), memory overhead (50-100MB per sidecar), and operational complexity.

11. Circuit Breakers & Rate Limiting

When a downstream service fails, the upstream service that calls it faces a critical decision: keep sending requests (which will all fail, wasting resources and adding latency) or stop sending requests (which allows the failed service time to recover, and protects the upstream from cascading failure). The circuit breaker pattern automates this decision, transitioning between "closed" (requests flow through), "open" (requests are immediately rejected), and "half-open" (a probe request tests recovery).

Circuit Breaker State Machine

The circuit breaker tracks the failure rate of requests to a downstream service. When the failure rate exceeds a threshold (e.g., 50% failures in a 60-second window), the circuit "opens" and all subsequent requests fail fast without calling the downstream service. After a timeout (e.g., 30 seconds), the circuit enters "half-open" state and allows one probe request through. If the probe succeeds, the circuit closes; if it fails, the circuit opens again with a fresh timeout. This pattern prevents cascading failures while allowing automatic recovery.

C#
public class CircuitBreaker
{
    private readonly object _lock = new();
    private CircuitState _state = CircuitState.Closed;
    private int _failureCount;
    private int _successCount;
    private DateTime _lastFailureTime;
    private DateTime _openedAt;

    private readonly int _failureThreshold = 5;
    private readonly TimeSpan _samplingWindow = TimeSpan.FromSeconds(60);
    private readonly TimeSpan _openTimeout = TimeSpan.FromSeconds(30);
    private readonly int _halfOpenMaxAttempts = 1;

    public async Task<T> ExecuteAsync<T>(
        Func<Task<T>> action,
        Func<Task<T>>? fallback = null)
    {
        lock (_lock)
        {
            if (_state == CircuitState.Open)
            {
                if (DateTime.UtcNow - _openedAt >= _openTimeout)
                {
                    _state = CircuitState.HalfOpen;
                    _successCount = 0;
                }
                else if (fallback != null)
                {
                    return fallback().Result; // Fast fail
                }
                else
                {
                    throw new CircuitOpenException(
                        $"Circuit open since {_openedAt:HH:mm:ss}");
                }
            }
        }

        try
        {
            var result = await action();
            OnSuccess();
            return result;
        }
        catch (Exception ex)
        {
            OnFailure();
            if (fallback != null) return await fallback();
            throw;
        }
    }

    private void OnSuccess()
    {
        lock (_lock)
        {
            _failureCount = 0;
            if (_state == CircuitState.HalfOpen)
            {
                _successCount++;
                if (_successCount >= _halfOpenMaxAttempts)
                {
                    _state = CircuitState.Closed;
                    Console.WriteLine("Circuit CLOSED (recovered)");
                }
            }
        }
    }

    private void OnFailure()
    {
        lock (_lock)
        {
            _failureCount++;
            _lastFailureTime = DateTime.UtcNow;

            if (_state == CircuitState.HalfOpen)
            {
                _state = CircuitState.Open;
                _openedAt = DateTime.UtcNow;
                Console.WriteLine("Circuit OPENED (probe failed)");
            }
            else if (_failureCount >= _failureThreshold)
            {
                _state = CircuitState.Open;
                _openedAt = DateTime.UtcNow;
                Console.WriteLine(
                    $"Circuit OPENED ({_failureCount} failures " +
                    $"in sampling window)");
            }
        }
    }
}

public enum CircuitState { Closed, Open, HalfOpen }

Rate Limiting

Rate limiting protects services from excessive load — whether from legitimate traffic spikes, misbehaving clients, or DDoS attacks. Without rate limiting, a single client can overwhelm a service by sending thousands of requests per second. Rate limiters enforce a maximum number of requests per client per time window, returning HTTP 429 (Too Many Requests) when the limit is exceeded.

Rate Limiting Algorithms

Fixed Window counts requests in a fixed time window (e.g., 100 requests per minute). Simple but causes burst issues at window boundaries — 100 requests at 11:59:59 + 100 requests at 12:00:00 = 200 requests in 2 seconds. Sliding Window smooths this by using a rolling window that considers the previous window's count. Token Bucket is the most popular algorithm: a bucket fills with tokens at a fixed rate, each request consumes one token, and requests are rejected when the bucket is empty. This naturally allows bursts (the bucket can hold accumulated tokens) while enforcing a sustained rate. Leaky Bucket processes requests at a fixed rate, queuing excess requests in a buffer. This provides smooth output but can increase latency during bursts.

C#
public class TokenBucketRateLimiter
{
    private readonly int _maxTokens;
    private readonly double _refillRate;  // tokens per second
    private double _currentTokens;
    private DateTime _lastRefill;
    private readonly object _lock = new();

    public TokenBucketRateLimiter(int maxTokens, int refillPerSecond)
    {
        _maxTokens = maxTokens;
        _refillRate = refillPerSecond;
        _currentTokens = maxTokens;
        _lastRefill = DateTime.UtcNow;
    }

    public bool TryAcquire(int tokens = 1)
    {
        lock (_lock)
        {
            Refill();
            if (_currentTokens >= tokens)
            {
                _currentTokens -= tokens;
                return true;
            }
            return false;
        }
    }

    private void Refill()
    {
        var now = DateTime.UtcNow;
        var elapsed = (now - _lastRefill).TotalSeconds;
        _currentTokens = Math.Min(
            _maxTokens,
            _currentTokens + elapsed * _refillRate);
        _lastRefill = now;
    }
}

// Rate limiting middleware
public class RateLimitingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ConcurrentDictionary<string, TokenBucketRateLimiter>
        _clientLimiters = new();
    private readonly int _requestsPerMinute = 100;

    public async Task InvokeAsync(HttpContext context)
    {
        var clientId = GetClientId(context);
        var limiter = _clientLimiters.GetOrAdd(
            clientId,
            _ => new TokenBucketRateLimiter(_requestsPerMinute,
                _requestsPerMinute / 60));

        if (!limiter.TryAcquire())
        {
            context.Response.StatusCode = 429;
            context.Response.Headers["Retry-After"] = "60";
            await context.Response.WriteAsJsonAsync(new
            {
                error = "Rate limit exceeded",
                retryAfter = 60
            });
            return;
        }

        await _next(context);
    }
}
AlgorithmBurst HandlingMemoryAccuracyBest For
Fixed WindowAllows bursts at boundariesO(1)Low (boundary bursts)Simple APIs with tolerant clients
Sliding Window LogSmooth enforcementO(n) per clientHighPrecise rate limiting
Token BucketControlled burstsO(1)Medium-HighAPIs with burst traffic patterns
Leaky BucketSmooth outputO(n) queueHighTraffic shaping, QoS
AdaptiveDynamic based on loadO(n)HighAuto-scaling backends
Rate Limiting in Distributed Systems: A single rate limiter instance works for one server. For multiple servers, you need a distributed rate limiter. Options include: (1) Redis-based centralized counter (atomic INCR + EXPIRE), (2) Local rate limiters with periodic synchronization (approximate but fast), (3) Sticky routing (same client always hits same server via consistent hashing). Option 3 is the most performant — if the load balancer uses consistent hashing on client ID, each server only needs a local rate limiter.

12. Global Load Balancing (GSLB)

Global Server Load Balancing (GSLB) distributes traffic across data centers or regions, not just servers within a single data center. When a user in Tokyo accesses your application, GSLB routes them to the nearest healthy data center (Tokyo), not to a data center in Virginia. GSLB operates at the DNS level (for geographic routing) or at the edge proxy level (for latency-based routing), and it must handle the full range of challenges: data center failures, regional network partitions, and compliance requirements that restrict data residency.

Routing Strategies

GSLB supports multiple routing strategies. Geo-based routing directs users to the nearest data center based on their IP address's geographic location. This minimizes network latency — a user in Frankfurt hits Frankfurt, not São Paulo. Latency-based routing measures actual latency to each data center and routes to the fastest one. This is more accurate than geo-based routing because network topology doesn't always align with geography (a user in London might have lower latency to Dublin than to Paris). Weighted routing distributes traffic across data centers in configurable proportions — useful for canary deployments where 5% of traffic goes to a new region. Failover routing detects data center failures and reroutes traffic to healthy data centers.

C#
// GSLB routing decision engine
public class GlobalLoadBalancer
{
    private readonly IGeoIpLookup _geoIp;
    private readonly ILatencyProber _latencyProber;
    private readonly IHealthMonitor _healthMonitor;
    private readonly List<DataCenter> _dataCenters;

    public DataCenter RouteRequest(HttpRequest request)
    {
        var clientIp = request.Headers["X-Forwarded-For"]
            .FirstOrDefault() ?? request.HttpContext
            .Connection.RemoteIpAddress?.ToString();

        // Filter to healthy data centers only
        var healthy = _dataCenters
            .Where(dc => _healthMonitor.IsHealthy(dc.Id))
            .ToList();

        if (!healthy.Any())
        {
            // All data centers unhealthy — use closest anyway
            healthy = _dataCenters;
        }

        // Strategy 1: Geographic proximity
        var geoResult = _geoIp.Lookup(clientIp);
        var byProximity = healthy
            .OrderBy(dc => CalculateDistance(
                geoResult.Latitude, geoResult.Longitude,
                dc.Latitude, dc.Longitude))
            .ToList();

        // Strategy 2: Actual latency measurement
        var byLatency = healthy
            .OrderBy(dc => _latencyProber
                .GetLatency(clientIp, dc.IpRange))
            .ToList();

        // Combine: prefer latency if available, fall back to geo
        return byLatency.FirstOrDefault() ?? byProximity.First();
    }

    private double CalculateDistance(
        double lat1, double lon1, double lat2, double lon2)
    {
        // Haversine formula for great-circle distance
        var dLat = ToRadians(lat2 - lat1);
        var dLon = ToRadians(lon2 - lon1);
        var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
                Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2)) *
                Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
        return 6371 * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
    }
}

Anycast BGP

The most sophisticated GSLB technique is Anycast BGP (Border Gateway Protocol). With Anycast, multiple data centers advertise the same IP address range via BGP. The internet's routing infrastructure naturally directs each user to the nearest data center that announces that IP range. Cloudflare, Google, and Facebook use Anycast for their CDN and DNS infrastructure. The advantage is that routing happens at the network level, with zero application-layer overhead. The disadvantage is that BGP convergence can take minutes during failures, and traffic engineering is complex.

graph TB subgraph Users["Global Users"] U1["Tokyo User"] U2["New York User"] U3["London User"] end subgraph GSLB["Global Load Balancer"] DNS["DNS/GSLB Layer"] end subgraph Regions["Data Center Regions"] DC1["us-east-1
Virginia"] DC2["eu-west-1
Dublin"] DC3["ap-northeast-1
Tokyo"] end U1 --> DNS U2 --> DNS U3 --> DNS DNS -->|"Lowest latency"| DC3 DNS -->|"Lowest latency"| DC1 DNS -->|"Lowest latency"| DC2
GSLB MethodRouting BasisFailover SpeedComplexityExamples
GeoDNSIP geolocationMinutes (DNS TTL)LowRoute53 geolocation routing
Latency-basedRTT measurementsSeconds (health checks)MediumRoute53 latency routing
Anycast BGPBGP path selectionMinutes (BGP convergence)HighCloudflare, Google DNS
Edge ProxyReal-time latency + healthSecondsHighCloudflare Load Balancer, Fastly
Data Residency Compliance: GDPR and similar regulations may restrict where user data can be processed. Your GSLB must respect data residency constraints — a European user's requests should only route to EU data centers, even if a US data center is lower latency. Implement this by tagging users with their data residency requirement and filtering the GSLB candidate list before latency-based selection.

13. Cloud Load Balancer Comparison (AWS ALB/NLB, GCP, Azure)

Cloud providers offer managed load balancers that abstract away infrastructure management. Understanding their differences is critical for making the right choice. The three major providers — AWS, GCP, and Azure — each offer L4 and L7 load balancers with different pricing models, feature sets, and performance characteristics.

AWS Load Balancers

AWS offers two primary load balancers. The Application Load Balancer (ALB) is an L7 load balancer that handles HTTP, HTTPS, WebSocket, and gRPC traffic. It supports content-based routing, SSL termination, WAF integration, and target groups with health checks. The ALB charges per LCPU (load balancer capacity unit) and per hour, with additional charges for data processed. The Network Load Balancer (NLB) is an L4 load balancer that handles TCP, UDP, and TLS traffic with ultra-low latency (single-digit microseconds). It supports static IP addresses, making it ideal for whitelisting scenarios.

GCP Load Balancers

GCP offers the HTTP(S) Load Balancer for L7 traffic with global anycast IP addresses, meaning a single IP serves traffic globally with automatic nearest-region routing. The Network Load Balancer is a regional L4 load balancer using Maglev (Google's custom kernel-level load balancer). GCP's key advantage is global L7 load balancing with a single anycast IP — AWS ALB requires separate ALBs in each region behind Route53 for global distribution.

Azure Load Balancers

Azure offers the Application Gateway (L7, HTTP/HTTPS) with WAF v2 integration and URL-based routing. The Azure Load Balancer (L4) provides ultra-high performance with support for 10 million flows. Azure's Traffic Manager provides DNS-based global load balancing, similar to AWS Route53. Azure's unique advantage is deep integration with Active Directory and Azure AD for authentication at the load balancer level.

FeatureAWS ALBAWS NLBGCP HTTP(S) LBAzure App Gateway
OSI LayerL7L4L7 (Global)L7
ProtocolsHTTP, HTTPS, gRPC, WSTCP, UDP, TLSHTTP, HTTPSHTTP, HTTPS
Global DistributionRegional (needs Route53)RegionalGlobal anycast IPRegional (needs Traffic Mgr)
SSL TerminationYesPassthroughYesYes
Static IPNo (DNS only)YesYes (anycast)Yes
WAF IntegrationAWS WAFNoCloud ArmorWAF v2
Pricing ModelLCPU + hours + dataNLCU + hours + dataForwarding rules + dataSKUs + data
Max RPS1,000,000+10,000,000+2,000,000+500,000+
Health ChecksHTTP, TCPTCP, HTTPHTTP, HTTPS, HTTP/2HTTP
WebSocketYesYes (TCP passthrough)YesYes

Decision Framework

Choose AWS ALB for content-based routing within a region, microservice architectures, and when you need WAF integration. Choose AWS NLB for TCP/UDP workloads, database load balancing, static IP requirements, and ultra-low latency. Choose GCP HTTP(S) LB when you need global anycast with a single IP, serving global users without region-specific DNS. Choose Azure App Gateway when you're in the Azure ecosystem and need AD integration and WAF. For multi-cloud architectures, use a third-party load balancer (F5, HAProxy) or a service mesh (Istio) that works across all providers.

C#
// Terraform-style comparison of load balancer costs
public class LoadBalancerCostEstimate
{
    // AWS ALB pricing (us-east-1)
    public decimal AwsAlbHourlyCost => 0.0225m; // per hour
    public decimal AwsAlbLcpuCost => 0.008m;   // per LCPU per hour
    public decimal AwsAlbDataCost => 0.008m;   // per GB processed

    // AWS NLB pricing (us-east-1)
    public decimal AwsNlbHourlyCost => 0.0225m;
    public decimal AwsNlbNlcuCost => 0.005m;   // per NLCU per hour
    public decimal AwsNlbDataCost => 0.001m;

    // GCP HTTP(S) LB pricing
    public decimal GcpForwardingRuleCost => 0.025m; // per hour per rule
    public decimal GcpDataProcessingCost => 0.008m; // per GB

    // Monthly estimate for moderate traffic (10M requests/day, 1KB avg)
    public decimal EstimatedMonthlyAwsAlb =>
        (AwsAlbHourlyCost + 10 * AwsAlbLcpuCost) * 730m + // $184/mo base
        (10_000_000m * 30m * 1m / 1_000_000m) * AwsAlbDataCost; // $2.40/mo data

    // Total ~$187/month for ALB
}
Cost Optimization Tip: GCP's HTTP(S) Load Balancer includes free health checks and free global forwarding rules — you only pay for data processed. AWS ALB charges for LCPU hours even with zero traffic. For low-traffic applications, GCP's model is significantly cheaper. For high-throughput TCP workloads, AWS NLB is the most cost-effective at $0.001/GB processed vs $0.008/GB for ALB.

14. Performance Testing & Benchmarking

You cannot optimize what you cannot measure. Performance testing for load balancing and horizontal scaling requires measuring three categories: throughput (requests per second), latency (response time at various percentiles), and resource utilization (CPU, memory, network, connections). The goal is to find the maximum sustainable throughput before latency degrades beyond acceptable thresholds.

Key Metrics to Measure

The most important metric is not average latency — it is P95 or P99 latency. Average latency hides tail latency: if 99% of requests complete in 50ms and 1% take 5 seconds, the average is 100ms (looks acceptable) but the P99 is 5 seconds (unacceptable for most applications). Load balancing effectiveness is measured by the variance of latency across backend servers. If Server A has P99 of 50ms and Server B has P99 of 500ms, your load balancer is not distributing load evenly.

C#
// Load testing client for benchmarking
public class LoadTestRunner
{
    private readonly HttpClient _client;
    private readonly List<RequestMetric> _metrics = new();

    public async Task<LoadTestResult> RunAsync(
        string url, int concurrentUsers, TimeSpan duration)
    {
        var cts = new CancellationTokenSource(duration);
        var tasks = new List<Task>();

        for (int i = 0; i < concurrentUsers; i++)
        {
            tasks.Add(SendRequestsAsync(url, cts.Token));
        }

        await Task.WhenAll(tasks);

        return new LoadTestResult
        {
            TotalRequests = _metrics.Count,
            SuccessfulRequests = _metrics.Count(m => m.StatusCode == 200),
            FailedRequests = _metrics.Count(m => m.StatusCode != 200),
            AvgLatencyMs = _metrics.Average(m => m.LatencyMs),
            P50LatencyMs = Percentile(_metrics, 0.50),
            P95LatencyMs = Percentile(_metrics, 0.95),
            P99LatencyMs = Percentile(_metrics, 0.99),
            MaxLatencyMs = _metrics.Max(m => m.LatencyMs),
            RequestsPerSecond = _metrics.Count /
                duration.TotalSeconds
        };
    }

    private async Task SendRequestsAsync(
        string url, CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var sw = Stopwatch.StartNew();
            try
            {
                var response = await _client.GetAsync(url, ct);
                sw.Stop();
                _metrics.Add(new RequestMetric
                {
                    StatusCode = (int)response.StatusCode,
                    LatencyMs = sw.Elapsed.TotalMilliseconds
                });
            }
            catch (Exception)
            {
                sw.Stop();
                _metrics.Add(new RequestMetric
                {
                    StatusCode = 0,
                    LatencyMs = sw.Elapsed.TotalMilliseconds
                });
            }
        }
    }

    private double Percentile(
        List<RequestMetric> metrics, double percentile)
    {
        var sorted = metrics.OrderBy(m => m.LatencyMs).ToList();
        int index = (int)Math.Ceiling(percentile * sorted.Count) - 1;
        return sorted[Math.Max(0, index)].LatencyMs;
    }
}

Load Testing Tools

ToolLanguageProtocol SupportDistribution ModelBest For
k6 (Grafana)JavaScriptHTTP, WebSocket, gRPC, GraphQLSingle binary, cloud distributedAPI load testing, CI/CD integration
Apache JMeterJavaHTTP, JDBC, JMS, LDAPGUI + distributed modeEnterprise protocol testing
wrk2C + LuaHTTP, HTTPSSingle binary, multi-threadedRaw HTTP throughput benchmarking
LocustPythonHTTP, WebSocketPython distributedCustom protocol testing
hey / abGo / CHTTPSingle binaryQuick performance checks

Benchmarking Methodology

A proper load test follows a structured methodology. First, establish a baseline — measure single-server performance without any load balancing. This gives you the maximum per-server throughput and latency. Second, perform incremental load testing — start with 1 concurrent user and increase by 10x every 5 minutes until you reach the target load. This identifies the inflection point where latency starts degrading. Third, perform soak testing — run at target load for 24-72 hours to detect memory leaks, connection pool exhaustion, and other slow-developing issues. Fourth, perform stress testing — push beyond expected capacity to determine the system's breaking point and failure mode.

Measuring Load Balancer Effectiveness: The key metric for load balancer effectiveness is coefficient of variation (CV) of backend utilization. Calculate CPU usage across all backends every 5 seconds. A perfectly balanced load balancer has CV ≈ 0 (all backends at equal utilization). A CV above 0.3 indicates significant imbalance. Target CV below 0.15 for good balancing. Measure this continuously in production — not just during testing.

15. Cost Estimation

Understanding the cost of load balancing and horizontal scaling is essential for budget planning and architectural decisions. Costs fall into three categories: compute (the servers being load-balanced), load balancer infrastructure (the LB itself), and operational (monitoring, debugging, management).

Infrastructure Cost Breakdown

ComponentConfigurationMonthly CostNotes
AWS ALB1 ALB, moderate traffic$22-50Base + LCPU + data
AWS NLB1 NLB, high throughput$22-100Base + NLCU + data
GCP HTTP(S) LB1 LB, global$25-75Forwarding rules + data
Application servers (8x m5.large)8 instances, 2 vCPU, 8GB$800us-east-1 on-demand
Application servers (8x m5.large Spot)8 instances, 2 vCPU, 8GB$240~70% discount
Monitoring (Prometheus + Grafana)Self-hosted on EC2$1501x m5.large
CloudWatch / StackdriverStandard metrics + logs$200-500Varies with volume
Total (On-Demand)~$1,250
Total (Optimized)~$650Spot + right-sizing

Cost Optimization Strategies

There are five primary strategies for reducing load balancing and scaling costs. First, right-sizing — measure actual CPU and memory usage and select the smallest instance type that provides adequate headroom. Most applications are over-provisioned by 2-5x. Second, spot instances for stateless workers — since workers hold no state, they can be interrupted and restarted on a different machine at 60-70% cost savings. Third, scheduled scaling — scale down during off-peak hours. Most SaaS applications see 60-80% traffic reduction at night. Fourth, reserved capacity — commit to 1-year or 3-year reserved instances for predictable baseline load, paying 30-40% less than on-demand. Fifth, efficient load balancer selection — NLB is 8x cheaper per GB than ALB for workloads that don't need L7 features.

C#
// Cost modeling for horizontal scaling decisions
public class ScalingCostModel
{
    public decimal CalculateMonthlyCost(
        int instances,
        decimal onDemandPricePerHour,
        decimal spotDiscount = 0m,
        decimal reservedDiscount = 0m,
        decimal reservedPercentage = 0.8m)
    {
        decimal hoursPerMonth = 730m;
        decimal onDemandHours = instances * (1m - reservedPercentage);
        decimal reservedHours = instances * reservedPercentage;

        decimal onDemandCost = onDemandHours *
            onDemandPricePerHour * hoursPerMonth;
        decimal reservedCost = reservedHours *
            onDemandPricePerHour *
            (1m - reservedDiscount) * hoursPerMonth;

        return onDemandCost + reservedCost;
    }

    // Scenario: 20 m5.large instances ($0.096/hr on-demand)
    // 80% reserved (1yr) = 40% discount
    // 20% on-demand
    // Monthly cost: 20 * (16hrs * $0.096 + 570hrs * $0.0576)
    //            = 20 * ($1.536 + $32.832) = $687.36/month
}
Cost Comparison: Vertical vs Horizontal: A single m5.4xlarge ($1.536/hr, 16 vCPU, 64GB RAM) costs $1,121/month and handles ~40K req/s. Eight m5.large instances ($0.096/hr each, 2 vCPU, 8GB RAM) cost $561/month on-demand and handle ~32K req/s. With reserved pricing (40% discount), the 8-instance cluster costs $336/month while the single large instance costs $673/month. Horizontal scaling is 50% cheaper while providing better availability and fault tolerance.

16. Interview Q&A Deep Dive

Q1: What is the difference between L4 and L7 load balancing, and when would you choose each?

Answer: L4 load balancers operate at the TCP/UDP transport layer — they see IP addresses and port numbers but not HTTP content. They are extremely fast (microsecond latency) and ideal for non-HTTP protocols like databases, game servers, and TCP-based services. L7 load balancers operate at the HTTP application layer — they inspect the full request (method, path, headers, cookies) and can make content-based routing decisions. They add millisecond-level latency but enable powerful routing rules. Choose L4 for raw throughput and non-HTTP protocols. Choose L7 for web applications that need path-based routing, header-based routing, or SSL termination. In practice, many architectures use both: L4 for initial distribution, L7 for content-based routing within each cluster.

Q2: How do you prevent cascading failures when a downstream service is unhealthy?

Answer: Implement the circuit breaker pattern at the client side. When a downstream service returns a configurable number of consecutive failures (typically 5 in 60 seconds), the circuit "opens" and all subsequent requests fail fast without calling the downstream service. After a timeout (30-60 seconds), the circuit enters "half-open" state and allows a single probe request. If the probe succeeds, the circuit closes. Additionally, implement timeout budgets — propagate a deadline through the entire call chain so that if a downstream service is slow, the upstream service can fail fast rather than accumulating requests. Combine circuit breakers with retry budgets (limit retries to 20% of total requests) and bulkheads (isolate failure to a specific thread pool) for comprehensive cascading failure protection.

Q3: Explain consistent hashing and why it is preferred over modulo hashing for distributed caches.

Answer: Modulo hashing (server = hash(key) % N) remaps nearly ALL keys when N changes by even 1. If you have 10 cache servers and add 11th, 91% of keys are remapped, causing massive cache misses. Consistent hashing places both keys and servers on a ring, assigning each key to the next clockwise server. When a server is added, only keys in its immediate neighborhood (1/N of all keys, ~9%) are remapped. This minimizes cache misses during scaling events. Virtual nodes (150-200 per physical server) improve distribution uniformity from 35%/15% variance to within 2% of ideal. Consistent hashing is used by Redis Cluster, Cassandra, Amazon DynamoDB, and Netflix's Zuul.

Q4: How would you design a load balancer that handles 1 million requests per second?

Answer: Start with an L4 load balancer (NLB) as the entry point for raw throughput. Behind it, deploy multiple L7 load balancers (ALBs) for content-based routing. Use a two-tier architecture: Tier 1 (NLB) distributes by connection, Tier 2 (ALB) routes by HTTP path. For the application layer, deploy enough instances to handle 1M RPS — if each instance handles 10K RPS, you need 100 instances. Use consistent hashing for stateful requests and round-robin for stateless ones. For HTTP/2 and gRPC, implement client-side load balancing to avoid head-of-line blocking at the proxy. Monitor backend utilization with the coefficient of variation metric to ensure even distribution. Finally, pre-warm connection pools and DNS caches to avoid cold start issues during traffic spikes.

Q5: When would you use horizontal scaling over vertical scaling?

Answer: Use horizontal scaling when: (1) you need availability — horizontal scaling with multiple instances provides redundancy, while a single large instance is a single point of failure. (2) You expect unpredictable growth — adding instances is faster than upgrading hardware. (3) Your application is stateless or can externalize state — stateless services scale horizontally trivially. (4) You need cost efficiency — 8 small instances are typically cheaper than 1 large equivalent due to cloud pricing curves. Use vertical scaling when: (1) your application has strong consistency requirements that are hard to distribute (single-node databases). (2) You need simplicity — a single instance is simpler to deploy, debug, and operate. (3) The workload is non-parallelizable (Amdahl's Law) — adding more servers won't help if 80% of the work is serial.

Q6: How do you handle database scaling for a system that needs both high read throughput and strong consistency?

Answer: Use a read replica architecture with careful consistency handling. Primary handles all writes; read replicas handle read queries. For strong consistency on reads-after-writes, route reads to the primary for a short window (e.g., 500ms after a write). For the general case, accept eventual consistency on replicas (typically sub-second lag). Implement connection pooling (PgBouncer) to manage replica connection limits. For write scaling beyond a single primary, use sharding with consistent hashing — each shard is a primary with read replicas. The shard key should align with natural query boundaries (e.g., tenant_id for multi-tenant systems) to minimize cross-shard queries. Monitor replication lag and implement circuit breakers that temporarily route reads to the primary if lag exceeds a threshold (e.g., 5 seconds).

Q7: Explain the trade-offs between sticky sessions and stateless services.

Answer: Sticky sessions route all requests from a client to the same backend server, enabling in-memory session storage. The benefits: zero additional infrastructure, fast session reads (in-memory), and simplicity. The drawbacks: uneven load distribution, lost sessions when a server crashes, difficulty in deployments (must drain sessions before removing a server), and inability to scale beyond a single server's capacity. Stateless services externalize session data to Redis or a distributed cache. Any server can serve any request because session data is not in-memory. The benefits: unlimited horizontal scaling, fault tolerance (sessions survive server failures), simple deployments, and even load distribution. The drawbacks: additional Redis infrastructure, slightly higher latency for session reads (network hop to Redis), and cache invalidation complexity. For new projects, always choose stateless — sticky sessions create architectural debt that compounds over time.

Q8: How do you test that your load balancing is working correctly?

Answer: Test at three levels. First, unit test the load balancing algorithm — verify that round-robin distributes evenly, least connections routes to the least loaded server, and consistent hashing minimizes redistribution. Second, integration test failover — kill a backend server and verify the load balancer removes it from the pool within the health check interval, distributes traffic to remaining servers, and restores the server when it recovers. Third, production monitoring — continuously track the coefficient of variation of backend utilization (target below 0.15), per-server latency percentiles (target less than 2x variance between servers), and connection counts (target within 20% of mean). Use distributed tracing (Jaeger, Zipkin) to verify that requests are distributed across backends. Finally, perform chaos testing — randomly terminate instances during load tests to verify the system self-heals.

Key Numbers to Remember

MetricValue
L4 LB latency overhead10-100 microseconds
L7 LB latency overhead1-10 milliseconds
Health check interval (typical)5-30 seconds
Failover detection time15-60 seconds
Virtual nodes per server (consistent hash)150-250
Max P99 latency target (web apps)< 500ms
AWS NLB max connections10 million
AWS ALB max RPS1,000,000+
Cost savings (reserved vs on-demand)30-40%
Cost savings (spot instances)60-70%
Circuit breaker default threshold5 failures in 60s
Rate limiter burst allowance (token bucket)2-3x sustained rate

Pre-Interview Checklist

  • Explain L4 vs L7 load balancing with specific use cases
  • Know at least 4 load balancing algorithms and their tradeoffs
  • Describe circuit breaker state machine (closed → open → half-open)
  • Explain consistent hashing with virtual nodes and why modulo hashing fails
  • Discuss horizontal vs vertical scaling with cost comparisons
  • Know auto-scaling policies: reactive, predictive, and hybrid
  • Explain database scaling: read replicas, sharding, and consistency trade-offs
  • Describe service discovery patterns (client-side, server-side, DNS, mesh)
  • Know rate limiting algorithms (token bucket, sliding window, leaky bucket)
  • Understand GSLB strategies (geoDNS, latency-based, anycast BGP)
  • Be able to estimate infrastructure costs for a given architecture
  • Discuss cascading failure prevention (circuit breakers + bulkheads + retry budgets)
System Design Framework: When asked to design a system that requires load balancing, start with: (1) What layer? L4 or L7. (2) What algorithm? Round-robin, least-connections, or consistent hash. (3) Health checks — interval, timeout, thresholds. (4) Session handling — stateless or affinity? (5) Auto-scaling — what metrics, what thresholds? (6) Failure modes — what happens when the LB itself fails? Answering these six questions covers 90% of load balancing design discussions.

Load Balancing & Horizontal Scaling — Senior+ Guide | Ayodhyya