Quizzr Logo

Load Balancing

Ensuring Session Persistence Using Client IP Hashing

Learn how to maintain user state without cookies by mapping client IPs to specific backend servers. This guide covers the mechanics of consistent hashing and how to handle server failovers in stateful environments.

Cloud & InfrastructureIntermediate12 min read

Beyond Round Robin: The Need for Session Persistence

In a perfectly stateless world, any request from any client can be handled by any available server in a pool. This architectural ideal allows load balancers to use simple algorithms like round robin to distribute traffic with minimal overhead. However, real-world applications often face constraints that require a specific user to remain tethered to a specific backend instance.

Consider a high-frequency financial trading application or a real-time multiplayer gaming server where sub-millisecond latency is critical. In these scenarios, fetching session state from a centralized cache like Redis for every single request introduces unacceptable network overhead. Storing that state in the local memory of the application server provides the performance needed, but it creates a dependency between the client and that specific server.

This dependency is known as session persistence or sticky sessions. While many developers reach for cookie-based persistence, this approach is not always viable for non-HTTP traffic or clients that do not support cookies. IP-based persistence provides a robust alternative by using the source IP address as the primary key for distribution logic.

The goal of stateful load balancing is to maintain a high cache hit ratio on the server level while ensuring that no single node becomes a performance bottleneck due to uneven traffic distribution.

The Limitations of Simple Modulus Hashing

The most basic way to implement IP-based persistence is using the modulus operator. A load balancer takes the integer value of an incoming IP address and calculates the remainder when divided by the number of active servers in the pool. This result determines which server receives the traffic.

This approach is computationally inexpensive and works well in a static environment where the server count never changes. If you have four servers, an IP mapping to index two will always go to the third server in the list. The simplicity of this algorithm makes it attractive for initial implementations.

The architectural flaw appears during scaling events or server failures. If one server is removed from a pool of four, the divisor changes from four to three, which recalculates the destination for almost every IP address in the system. This massive reshuffling triggers a cache miss storm, as every client is suddenly routed to a server that lacks their local session data.

Architecting Robustness with Consistent Hashing

Consistent hashing solves the massive reshuffling problem by decoupling the number of servers from the mapping logic. Instead of mapping IPs directly to a list of servers, both the clients and the servers are mapped onto a logical circular structure known as a hash ring. This ring represents the entire range of possible hash values produced by a function like SHA-256.

Each server is assigned one or more positions on this ring based on its unique identifier. When a request arrives, the load balancer hashes the client IP and finds its position on the same ring. The request is then routed to the first server it encounters while moving clockwise around the circle.

pythonBasic Consistent Hashing Concept
1import hashlib
2
3class ConsistentHashRing:
4    def __init__(self, nodes=None):
5        # Stores the sorted hash values of the servers
6        self.ring = {}
7        self.sorted_keys = []
8        if nodes:
9            for node in nodes:
10                self.add_node(node)
11
12    def add_node(self, node_name):
13        # Generate a hash for the server and place it on the ring
14        key = self._hash(node_name)
15        self.ring[key] = node_name
16        self.sorted_keys.append(key)
17        self.sorted_keys.sort()
18
19    def get_node(self, client_ip):
20        # Find the first server clockwise from the client hash
21        if not self.ring:
22            return None
23        client_hash = self._hash(client_ip)
24        for key in self.sorted_keys:
25            if client_hash <= key:
26                return self.ring[key]
27        # Wrap around to the first server if no key is larger
28        return self.ring[self.sorted_keys[0]]
29
30    def _hash(self, val):
31        return int(hashlib.md5(val.encode()).hexdigest(), 16)

The primary benefit of this design is that adding or removing a server only affects a small fraction of the keys. If a server is removed, only the clients that were previously mapped to it will migrate to its immediate neighbor on the ring. All other clients remain connected to their original servers, preserving their local session state.

Virtual Nodes and Traffic Distribution

A common issue with basic consistent hashing is hotspots, where a single server ends up responsible for a disproportionately large segment of the ring. This happens because physical servers might not be distributed evenly across the hash space by a single hash function. If two servers are placed very close to each other, the server downstream will receive very little traffic.

Virtual nodes or vnodes address this by mapping each physical server to hundreds of logical points on the ring. Instead of one entry for Server-A, the ring contains entries for Server-A-1, Server-A-2, and so on. This ensures that the responsibility for the total IP space is fragmented and distributed more granularly.

  • Vnodes allow for heterogeneous clusters by assigning more virtual nodes to high-capacity servers.
  • They minimize the impact of server failure by spreading the affected load across many remaining nodes.
  • Virtual nodes simplify the process of rebalancing the cluster without changing the hashing algorithm.

Implementation Realities and Edge Cases

Implementing IP-based persistence requires more than just a clever hashing algorithm. You must account for how client IPs appear at the network layer, especially when dealing with users behind corporate proxies or NAT gateways. In these environments, thousands of unique users might share a single public IP address.

When a large group of users shares an IP, a load balancer using IP hashing will send all of them to the same backend server. This can lead to unexpected load spikes on a single node even if your hash ring is mathematically balanced. Monitoring per-server request rates is essential to detect these imbalances in real time.

goServer Health and Ring Management
1type LoadBalancer struct {
2    ring *HashRing
3    mu   sync.RWMutex
4}
5
6// HandleRequest determines the destination for an incoming IP
7func (lb *LoadBalancer) HandleRequest(clientIP string) string {
8    lb.mu.RLock()
9    defer lb.mu.RUnlock()
10    
11    // Find the target server based on the consistent hash
12    target := lb.ring.GetNode(clientIP)
13    
14    // Verify the target is healthy before routing
15    if isHealthy(target) {
16        return target
17    }
18    
19    // Fallback logic if the primary sticky node is down
20    return lb.findNextAvailable(clientIP)
21}

Another critical factor is the handling of IPv6 addresses. Since IPv6 has a significantly larger address space, your hashing function must be able to handle 128-bit identifiers without excessive collisions. It is often a best practice to hash a subnet mask rather than the full address if clients are expected to roam within a specific network range.

Failover Strategies for Stateful Nodes

Even with consistent hashing, a server failure inevitably results in session loss for some users. To mitigate the impact, you can implement a primary-secondary persistence model. The load balancer identifies the two closest nodes on the ring for a given IP hash.

Under normal conditions, all traffic goes to the primary node. Meanwhile, the application layer can asynchronously replicate session data from the primary to the secondary node. If the load balancer detects a failure in the primary, it automatically shifts the traffic to the secondary, where the session state is already present.

This architectural pattern balances the performance of local memory access with the reliability of distributed systems. It requires careful coordination between the infrastructure layer and the application code to ensure that data replication is faster than the load balancer's failover detection interval.

Evaluating Trade-offs and Best Practices

IP-based persistence is a powerful tool, but it is not a universal replacement for shared state management. You must weigh the complexity of maintaining a consistent hash ring against the requirements of your application. If your state is small and rarely accessed, a centralized store like DynamoDB might be more cost-effective.

For teams moving toward a containerized environment with frequent deployments, IP hashing can be challenging. Each time a new container version is rolled out, the ring changes, causing session drops. To solve this, implement a graceful shutdown period where old containers remain active until all existing sessions assigned to them have naturally expired.

Never assume that an IP address represents a single user; always design your backend to handle the possibility of sudden traffic surges from a single hash point.

Finally, ensure that your load balancer's health check mechanism is tightly integrated with the hash ring. A node should be removed from the ring immediately upon failure to prevent a black-hole effect where requests are routed to a dead server. Conversely, when a node returns to health, it should be re-integrated gradually to prevent its local cache from being overwhelmed by a sudden flood of requests.

We use cookies

Necessary cookies keep the site working. Analytics and ads help us improve and fund Quizzr. You can manage your preferences.