Consistent Hashing Explained: How Distributed Systems Balance Load Without Coordination
Naive modular hashing falls apart the moment you add or remove a node. Consistent hashing solves this with a ring structure and virtual nodes. Here is how it works, why it matters, and how to implement it in TypeScript.
Every distributed system eventually faces the same problem: you have data spread across N nodes, and you need to route any given request to the right node without a central coordinator keeping a lookup table. The naive answer is modular hashing. The correct answer, once you understand why modular hashing breaks, is consistent hashing.
This article covers consistent hashing from first principles: what breaks with naive approaches, how the ring model fixes it, why virtual nodes matter for real-world distributions, and what the tradeoffs look like in production systems.
The Problem With Modular Hashing
The simplest partitioning scheme is hash(key) % N, where N is the number of nodes. Given a key, you hash it and take the remainder to determine which node owns that key.
function getNode(key: string, nodes: string[]): string {
const hash = murmurhash(key); // some integer hash
return nodes[hash % nodes.length];
}
This works well under a fixed topology. The problem surfaces the moment you scale.
Suppose you have 4 nodes and you add a 5th to handle load. Now hash(key) % 5 routes to a completely different node than hash(key) % 4 for most keys. In practice, roughly (N-1)/N of all keys need to move to new nodes when you add one node to a cluster of size N. With 4 nodes, that is 75% of your keyspace migrating on a single topology change.
For a cache, this means a thundering herd: every key that moves becomes a cache miss simultaneously, hammering your backing store. For a database, it means an expensive rebalancing job. For a distributed lock system, it means correctness issues if multiple nodes believe they own the same lock key during the transition.
The fundamental flaw is that the entire mapping function changes when N changes. You want a scheme where adding or removing a node only affects the keys that were assigned to that node, not the entire keyspace.
The Hash Ring
Consistent hashing solves this by treating the hash space as a ring. Instead of computing hash(key) % N, you:
- Map each node to one or more points on a circular hash space (for example, [0, 2^32)).
- Map each key to a point on the same ring.
- Route the key to the first node clockwise from its position on the ring.
When you add a node, it takes ownership of only the keys between itself and its predecessor. When you remove a node, those keys move to the next node clockwise. No other keys are affected.
class ConsistentHashRing {
private ring: Map<number, string> = new Map();
private sortedHashes: number[] = [];
addNode(node: string): void {
const hash = this.hash(node);
this.ring.set(hash, node);
this.sortedHashes = [...this.ring.keys()].sort((a, b) => a - b);
}
removeNode(node: string): void {
const hash = this.hash(node);
this.ring.delete(hash);
this.sortedHashes = [...this.ring.keys()].sort((a, b) => a - b);
}
getNode(key: string): string | null {
if (this.sortedHashes.length === 0) return null;
const keyHash = this.hash(key);
// Find the first node hash >= keyHash (walk clockwise)
for (const nodeHash of this.sortedHashes) {
if (keyHash <= nodeHash) {
return this.ring.get(nodeHash)!;
}
}
// Wrap around: key falls after the last node, route to the first
return this.ring.get(this.sortedHashes[0])!;
}
private hash(input: string): number {
// FNV-1a 32-bit: fast, good distribution for this use case
let hash = 2166136261;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = (hash * 16777619) >>> 0; // keep 32-bit unsigned
}
return hash;
}
}
The binary search is more efficient for large rings:
getNode(key: string): string | null {
if (this.sortedHashes.length === 0) return null;
const keyHash = this.hash(key);
let lo = 0, hi = this.sortedHashes.length - 1;
// Binary search for the first hash >= keyHash
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.sortedHashes[mid] < keyHash) {
lo = mid + 1;
} else {
hi = mid;
}
}
// Wrap around if keyHash > all node hashes
const idx = this.sortedHashes[lo] >= keyHash ? lo : 0;
return this.ring.get(this.sortedHashes[idx])!;
}
With this structure, adding node E to a ring of A, B, C, D only moves the keys that previously belonged to E’s successor on the ring. Everything else stays put.
Virtual Nodes: Solving the Distribution Problem
Basic consistent hashing with one hash point per physical node has a distribution problem. With 4 nodes on a 32-bit ring, the ring is divided into 4 arcs of highly variable size. The probability that any two adjacent node hashes are far apart is significant, meaning some nodes end up owning much larger keyspace slices than others.
In practice, with a small number of physical nodes, the load distribution can be highly uneven. One node might own 40% of the keyspace while another owns 10%.
Virtual nodes (vnodes) fix this. Instead of placing each physical node at one point on the ring, you place it at V points, using different hash inputs for each:
class VirtualNodeRing {
private ring: Map<number, string> = new Map();
private sortedHashes: number[] = [];
private readonly virtualNodes: number;
constructor(virtualNodes = 150) {
this.virtualNodes = virtualNodes;
}
addNode(node: string): void {
for (let i = 0; i < this.virtualNodes; i++) {
const hash = this.hash(`${node}#${i}`);
this.ring.set(hash, node);
}
this.sortedHashes = [...this.ring.keys()].sort((a, b) => a - b);
}
removeNode(node: string): void {
for (let i = 0; i < this.virtualNodes; i++) {
const hash = this.hash(`${node}#${i}`);
this.ring.delete(hash);
}
this.sortedHashes = [...this.ring.keys()].sort((a, b) => a - b);
}
getNode(key: string): string | null {
if (this.sortedHashes.length === 0) return null;
const keyHash = this.hash(key);
const idx = this.lowerBound(keyHash);
return this.ring.get(this.sortedHashes[idx])!;
}
private lowerBound(target: number): number {
let lo = 0, hi = this.sortedHashes.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.sortedHashes[mid] < target) lo = mid + 1;
else hi = mid;
}
return this.sortedHashes[lo] >= target ? lo : 0;
}
private hash(input: string): number {
let hash = 2166136261;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = (hash * 16777619) >>> 0;
}
return hash;
}
}
With 150 virtual nodes per physical node, the load distribution approaches uniformity. The statistical variance in arc lengths drops significantly because each physical node contributes many small segments distributed across the ring rather than one large arc.
Virtual nodes also make heterogeneous clusters straightforward. A node with twice the capacity gets twice as many virtual nodes, so it naturally receives twice the traffic.
// Weighted allocation: high-capacity nodes get more virtual slots
addNode(node: string, weight: number = 1): void {
const slots = Math.floor(this.virtualNodes * weight);
for (let i = 0; i < slots; i++) {
const hash = this.hash(`${node}#${i}`);
this.ring.set(hash, node);
}
this.sortedHashes = [...this.ring.keys()].sort((a, b) => a - b);
}
Replication With Consistent Hashing
Most real systems need replication. With the ring in place, replication is straightforward: for replication factor R, a key is owned by the first R distinct physical nodes clockwise from its position.
getReplicaNodes(key: string, replicationFactor: number): string[] {
if (this.sortedHashes.length === 0) return [];
const keyHash = this.hash(key);
const startIdx = this.lowerBound(keyHash);
const replicas = new Set<string>();
let idx = startIdx;
while (replicas.size < replicationFactor && replicas.size < this.physicalNodeCount()) {
const node = this.ring.get(this.sortedHashes[idx])!;
replicas.add(node); // Set deduplicates virtual nodes for the same physical node
idx = (idx + 1) % this.sortedHashes.length;
}
return [...replicas];
}
This is exactly how Dynamo-style systems implement replication. The preference list for a key is the ordered set of distinct physical nodes starting at the key’s position on the ring.
Tradeoffs
| Approach | Remapping on topology change | Load balance | Heterogeneous nodes | Complexity |
|---|---|---|---|---|
Modular hashing (hash % N) | ~(N-1)/N keys remapped | Even (if hash is good) | Not supported | Minimal |
| Basic consistent hashing (1 vnode) | Only affected keys | Poor with few nodes | Not supported | Low |
| Consistent hashing with vnodes | Only affected keys | Good (150+ vnodes) | Weight-based | Medium |
| Rendezvous hashing | Only affected keys | Excellent | Weight-based | Low |
| Jump consistent hash | ~1/N keys remapped | Excellent | Not supported | Low |
Rendezvous hashing (highest random weight) is worth knowing as an alternative. For each key, you compute hash(key, node) for every node and pick the highest score. It achieves near-perfect distribution and handles topology changes as well as consistent hashing, but lookup is O(N) rather than O(log N). For small node counts this is fine; for large clusters the linear scan becomes a problem.
Jump consistent hash produces near-perfect balance and O(ln N) lookup time, but it only supports sequential node IDs and does not handle arbitrary node removal, limiting its applicability to certain storage backends.
Consistent hashing with virtual nodes is the right default for most distributed systems because it handles arbitrary membership changes, supports weighted nodes, and provides good distribution at the cost of moderate implementation complexity.
Where You See This in Production
Key-value stores: The foundational paper for consistent hashing in production is the Amazon Dynamo paper (2007). DynamoDB uses a variant of this with token-based partitioning where each node is responsible for a range of the ring rather than individual points. The virtual node concept maps directly to what Dynamo calls “virtual nodes” in its architecture.
Distributed caches: Redis Cluster divides the hash space into 16,384 fixed slots, then assigns slot ranges to nodes. This is a discrete version of consistent hashing. Adding or removing a node means migrating the affected slot ranges, with clients updated via cluster state propagation. The slot-based approach makes membership state compact to represent and broadcast.
Content delivery networks: CDNs use consistent hashing to route requests for a given URL to the same edge cache node, maximizing cache hit rate. When a node goes down, requests for its keys automatically route to the next node on the ring, which then fetches from origin. The percentage of cache misses caused by node failure is proportional to that node’s share of the ring, not the entire cache.
Service meshes and load balancers: Consistent hashing enables session affinity without sticky sessions at the infrastructure level. A load balancer hashing on client IP or session token routes requests to the same backend for the duration of a connection, which matters for stateful protocols, connection pooling, and local in-process caches on backend nodes.
Production Considerations
Hash function choice: Use a good non-cryptographic hash. MD5 and SHA-1 are overkill. MurmurHash3 and FNV-1a both provide good avalanche properties with low latency. The important property is avalanche: small changes in the input should produce large changes in the output, ensuring that sequential keys land on different nodes.
Virtual node count: 150 virtual nodes per physical node is a commonly used default and gives reasonably tight distribution. Lower values (50-100) trade distribution quality for memory. Higher values (300+) give better distribution but consume more memory for the ring index and slow ring operations when nodes join or leave. Measure the actual distribution in your cluster before tuning.
Hotspot detection: Even with good average distribution, certain keys can become hotspots if access patterns are skewed. The ring handles topology change well but does not solve access frequency imbalance. You still need application-level hotspot detection, key sharding for high-cardinality hot keys, or explicit overrides for known heavy hitters.
Membership protocol: The ring itself is just a data structure. In a real distributed system, nodes need a way to agree on ring membership. This is typically handled by a gossip protocol or a coordination service. Nodes that have inconsistent views of the ring will route the same key to different nodes, causing reads to miss writes. Ensure your membership propagation is fast relative to your consistency requirements.
Node weights in practice: If you use weighted virtual nodes, make weight changes carefully. Changing a node’s weight triggers rebalancing proportional to the weight delta. A node going from weight 1 to weight 2 effectively doubles its virtual node count, pulling roughly half of another node’s keys to itself. Do this during low-traffic windows and monitor migration progress.
Graceful node removal: Before removing a node, migrate its data to its successors. The ring tells you exactly which node takes over (the successor on the ring), so targeted migration is straightforward. Removing without migrating means data loss unless successors have replicas. Most production systems enforce a drain-before-remove protocol at the operator tooling level.
The Core Insight
The key insight of consistent hashing is that it decouples the routing function from the cluster size. Modular hashing bakes cluster size into the routing function itself, so any size change invalidates the entire function. Consistent hashing puts nodes and keys in the same address space, so the routing function is defined entirely by proximity: find the nearest node in one direction.
This locality property is what makes consistent hashing compose well with replication, weighted allocation, and heterogeneous hardware. The ring is a single abstraction that handles all of these concerns through the same traversal operation, without coordination between nodes at request time.
When you are choosing a partitioning scheme for a new distributed system, the question is not whether to use consistent hashing but which variant fits your constraints. Fixed slots like Redis Cluster simplify the membership state at the cost of flexibility. Jump consistent hash maximizes balance for stable topologies. Virtual nodes give you the full generality of arbitrary membership changes with good distribution. Know the tradeoffs and pick accordingly.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.