Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Consistent Hashing Ring
How distributed caches and databases decide which node owns a key — while keeping churn tiny when nodes join or leave. Build a hash ring with virtual nodes and route keys to the next node clockwise. A provided hash keeps Python and TypeScript in lock-step. Solve it in your browser, with hidden tests.
The problem
Place each of the nodes on a hash ring at vnodes points (virtual nodes), using the provided hash32. To route a key, hash it and walk clockwise to the first ring point at or after it (wrapping past the end back to the smallest). Implement assign(keys, nodes, vnodes): return, for each key, the name of the node that owns it. The point for node n’s replica r is hash32(n + "#" + r).
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Build a list of (point, node) for every node × replica, sort it by point, and for each key find the first point ≥ hash32(key) (wrapping to index 0 if none). Return the owning node per key.
Examples and constraints 4 examples
assign(['k1','k2','k3'], ['A','B','C'], 3)['C', 'A', 'C']assign(['k1','k2','k3'], ['A','B','C','D'], 3)['C', 'A', 'C']assign(['k207'], ['A','B','C'], 3)['B']assign(['k1','k2','k3'], ['A','B','C'], 1)['C', 'C', 'C']- Use the provided
hash32for both node points and keys (do not invent your own — the tests depend on it). - Each node contributes
vnodesring points; more virtual nodes → smoother load balance. - A key hashing past the largest ring point wraps around to the smallest point.
- The routing must be deterministic: the same key always maps to the same node for a fixed ring.
Hints 4 ideas
- Ring points: for each node
nand replicarin0…vnodes-1, add(hash32(n + "#" + r), n). Sort by the point. - To route a key, compute
kh = hash32(key)and scan the sorted ring for the first point≥ kh. - If no point is
≥ kh(the key is past the last point), wrap around to the first point — the ring is circular. - Virtual nodes are the trick that makes load even and keeps re-mapping small when a node is added or removed.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious way to spread keys over nodes is modulo hashing: nodes[hash32(key) % len(nodes)]. It works as far as it goes: it is deterministic, close to uniform, and O(1) per key, so O(K) time and O(1) extra space for K keys. It does not produce ring ownership (on the first example it gives ['A', 'B', 'A'], not ['C', 'A', 'C']), and its real failure is churn. A key keeps its node only if h % N == h % (N + 1); going from 3 nodes to 4 that holds for 1 key in 4, and measured on the 10,000 keys user0 to user9999 with this hash32, 7,502 changed owner. In a cache, every one of them is a miss.
The approach
Put nodes and keys on the same circle of 32-bit hash values, and let each key belong to the first node point clockwise from it. The insight is locality: a node owns only the arcs that end at its own points, so a joining node takes keys only from the arcs it lands in, and a leaving node hands its arcs to the next points clockwise. No other key moves. Virtual nodes give each node vnodes scattered points, so the arcs even out. The invariant is that ring is sorted by point, which makes “the first point at or after kh” well defined, and wrapping to ring[0] closes the circle.
The reference builds ring from (hash32(n + "#" + str(r)), n) for every node and replica and calls ring.sort(). For each key it computes kh = hash32(key), sets owner = ring[0][1] as the wrap-around default, then scans for the first point >= kh and breaks. The scan is linear; because the ring is sorted, bisect_left on the points finds the same index in O(log R).
Walk through an example
The first example builds nine points (three nodes, three replicas each), sorts them, and routes each key to the first point at or above its hash.
| step | doing | values | owner |
|---|---|---|---|
| 1 | build ring | 9 pairs (hash32(n + "#" + str(r)), n) for A, B, C and r = 0, 1, 2 | — |
| 2 | ring.sort() | B 129767782 · C 1184223885 · B 1528284164 · A 3004455060 · C 3554822470 · A 3709679897 · C 3935828790 · A 4085209643 · B 4184374926 | — |
| 3 | route "k1" | kh = 848568852; ring[0] is smaller, ring[1] = 1184223885 is not | C |
| 4 | route "k2" | kh = 1864395268; ring[0] to ring[2] are smaller, ring[3] = 3004455060 is not | A |
| 5 | route "k3" | kh = 791947752; stops at ring[1] = 1184223885 | C |
| 6 | return | — | ['C', 'A', 'C'] |
Complexity
Reference: sorting R = N · vnodes points is O(R log R), and each of K keys scans up to R of them: O(R log R + K · R) time, O(R + K) space. bisect cuts lookups to O(K log R). Modulo hashing: O(K) time and O(1) extra space, but going from N to N + 1 nodes remaps about N/(N + 1) of the keys.
Edge cases to test
- A key past the largest point:
hash32("k207")is 4285853574, above the top point 4184374926, so the scan finds nothing and theowner = ring[0][1]default routes it toB. vnodes = 1on the first example: three points only, and all three keys land onC. Across the keysuser0touser9999, C took 7,997 of 10,000. That lumpiness is why virtual nodes exist.- A single node,
assign(["k1", "k2", "k3"], ["A"], 1): every key maps toA, whether it wraps or not. - No nodes, or
vnodes = 0: the ring is empty andring[0]raisesIndexError. The statement assumes at least one point; production code should reject an empty ring explicitly. - The same key twice,
["dup", "dup"]: both route to the same node, because routing is a pure function of the key and the ring. - Characters outside the Basic Multilingual Plane, such as the emoji U+1F600: the Python
hash32iterates code points and the TypeScript one iterates UTF-16 code units, so the hash is 2046440521 in Python and 3276203938 in TypeScript. The two languages agree only on BMP text.
Common mistakes
- Too few virtual nodes — the ring is lumpy and some nodes run hot.
- Falling back to
hash(key) % N: going from N to N + 1 nodes remaps about N/(N + 1) of the keys, 7,502 of the 10,000 keysuser0touser9999when a fourth node joins three. - Forgetting wrap-around: a key past the last node point belongs to the first node.
- In TypeScript, calling
ring.sort()with no comparator: it compares the pairs as strings, so[1184223885, "C"]sorts before[129767782, "B"]and “clockwise” stops meaning anything. Sort with(a, b) => a[0] - b[0].
Follow-ups an interviewer asks next
- Thousands of virtual nodes and millions of lookups: make routing faster.
- Keep the points in their own sorted list and binary-search it:
i = bisect_left(points, kh), ownerring[i % len(ring)][1]. That is O(log R) per key instead of O(R), and on the keysuser0touser9999it returns exactly what the linear scan returns. Build the ring once and reuse it, instead of rebuilding it on every call toassign. - A node joins or leaves. Which keys move, and how do you update the ring?
- Only keys in the arcs ending at that node’s points. With this
hash32, the 10,000 keysuser0touser9999and 50 vnodes, adding D moved 2,513 keys, all to D, and removing B moved exactly B’s 2,913. To update without a rebuild, insert or delete the node’s points in a sorted structure: O(vnodes · log R) in a balanced tree, O(vnodes · R) in a plain sorted array. - The nodes have different capacities.
- Give each node a number of virtual nodes proportional to its weight, so a machine with twice the memory gets twice the points and, on average, twice the keys. The routing code does not change, and removing a weighted node still moves only its own keys.
- Store every key on three nodes for durability.
- Walk clockwise from the key’s position and collect the first three distinct physical nodes, skipping points that belong to a node already picked. This is the preference-list idea from Dynamo-style stores, and with virtual nodes the replicas of one node’s keys are spread across many different neighbors.
Where this shows up
Consistent hashing is how distributed caches and databases (Dynamo, Cassandra, Riak, Memcached clients) spread keys so a membership change moves only ~K/N keys instead of the whole keyspace — turning a node add/remove from a cache-wide stampede into a small, local reshuffle.
Reference solution
Reference solution — Python
def hash32(s): # provided: xmur3 32-bit (matches TypeScript)
h = (1779033703 ^ len(s)) & 0xffffffff
for ch in s:
h = ((h ^ ord(ch)) * 3432918353) & 0xffffffff
h = ((h << 13) | (h >> 19)) & 0xffffffff
h = ((h ^ (h >> 16)) * 2246822507) & 0xffffffff
h = ((h ^ (h >> 13)) * 3266489909) & 0xffffffff
return (h ^ (h >> 16)) & 0xffffffff
def assign(keys, nodes, vnodes):
ring = [] # (point on the ring, node name)
for n in nodes:
for r in range(vnodes):
ring.append((hash32(n + "#" + str(r)), n))
ring.sort() # order points clockwise
out = []
for key in keys:
kh = hash32(key)
owner = ring[0][1] # default: wrap to the first point
for point, node in ring:
if point >= kh:
owner = node # first point at/after the key
break
out.append(owner)
return out
print(assign(['k1', 'k2', 'k3'], ['A', 'B', 'C'], 3)) # -> ['C', 'A', 'C']Reference solution — TypeScript
function hash32(s: string): number { // provided: xmur3 32-bit (matches Python)
let h = (1779033703 ^ s.length) >>> 0;
for (let i = 0; i < s.length; i++) {
h = Math.imul(h ^ s.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
h ^= h >>> 16;
return h >>> 0;
}
function assign(keys: string[], nodes: string[], vnodes: number): string[] {
const ring: [number, string][] = []; // [point, node]
for (const n of nodes)
for (let r = 0; r < vnodes; r++) ring.push([hash32(n + "#" + r), n]);
ring.sort((a, b) => a[0] - b[0]); // clockwise order
return keys.map((key) => {
const kh = hash32(key);
for (const [point, node] of ring) if (point >= kh) return node;
return ring[0][1]; // wrap around to the first point
});
}
console.log(assign(['k1', 'k2', 'k3'], ['A', 'B', 'C'], 3)); // -> ["C","A","C"]Questions about Consistent Hashing Ring
What is consistent hashing and why is it used?
Consistent hashing maps both keys and nodes onto the same circle of hash values and assigns each key to the first node found clockwise from it. Distributed caches and databases use it because changing the set of nodes moves only a small share of the keys: a new node takes over only the arcs it lands in, and a departing node hands its arcs to its clockwise neighbors. With plain modulo hashing, adding one node to three reassigns about three keys in four, which in a cache means a flood of misses.
What are virtual nodes in consistent hashing?
Virtual nodes are multiple ring positions for one physical node, produced here by hashing the node name with a replica number, such as A#0, A#1 and A#2. With one point per node the arcs are uneven: in this challenge, with one point each, node C owned 7,997 of the 10,000 keys user0 to user9999. More points per node average the arcs out, and with 200 each the three nodes held between 3,026 and 3,545 keys. Virtual nodes also spread a failed node’s load over many neighbors instead of one.
How many keys move when a node is added to a consistent hash ring?
About K divided by N, where K is the number of keys and N the new number of nodes, because the new node takes a share of the ring proportional to its points. Every moved key moves to the new node; no key moves between two old nodes. Measured with this challenge’s hash on the 10,000 keys user0 to user9999, adding a fourth node to three, with 50 virtual nodes each, moved 2,513 keys, all of them to the new node. Modulo hashing moved 7,502 of the same keys.
How do you find the next node clockwise efficiently?
Keep the ring sorted by hash value and binary-search it for the first point at or after the key’s hash, wrapping to the first point when the search runs off the end. In Python that is bisect_left on the list of points, and the resulting index modulo the ring size gives the owner. A lookup is then logarithmic in the number of ring points instead of linear. The reference solution scans linearly because the test rings hold at most 150 points.
This idea in other formats
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Circuit BreakerStop one failing dependency from taking down the fleet: trip after consecutive failures, fail fast while open, probe once after the cooldown. Implement the closed → open → half-open state machine as a pure, testable replay. Solve it in Python or TypeScript.Read →
- Raft Leader Election (Majority)The heartbeat of the Raft consensus algorithm: a candidate becomes leader only by winning a strict majority of the cluster — the rule that guarantees at most one leader per term. Decide an election round. Solve it in Python or TypeScript, with hidden tests.Read →
- Vector Clock MergeVector clocks let processes with no shared time agree on which event caused which. The key operation: on receive, take the element-wise max of the two clocks, then tick your own entry. Solve it in Python or TypeScript, with hidden tests.Read →
- CRDT: Grow-Only CounterA counter many replicas increment independently, with no coordination, that always converges to the same total after syncing — a G-Counter, the simplest CRDT. Merge per-replica payloads by element-wise max. Solve it in Python or TypeScript, with hidden tests.Read →
- Idempotency Key HandlerNetworks retry. A webhook fires twice; a payment is re-sent after a timeout. Without idempotency you double-charge or double-write. The fix: dedupe by a client-supplied key and always return the first result. Solve it in Python or TypeScript, with hidden tests.Read →
- CSV → Schema MapperEvery customer hands you a messy export. Before it flows into your system, each row must be coerced to a schema — strings to ints, "true" to booleans — and the rows that don’t fit quarantined, not silently dropped. Solve it in Python or TypeScript, with hidden tests.Read →