An interactive teaching tool that shows how consistent hashing places keys on a hash ring, how virtual nodes balance load, and how that compares to naive hash(key) % N assignment.
- Overview
- What is Consistent Hashing?
- Why Consistent Hashing?
- Features
- How It Works
- Architecture / Project Structure
- Algorithm
- Technical Implementation
- Tech Stack
- Installation
- Environment Variables
- Usage
- Example
- Complexity
- Design Decisions
- Edge Cases
- Tests
- Future Improvements
- Learning Resources
- Contributing
- Author
This project is a small full-stack visualizer for consistent hashing, the technique used by many distributed caches, databases, and load balancers to map keys to servers with minimal remapping when the cluster changes.
The Express API owns the ring: hashing, virtual nodes, assignment, migration diffs, statistics, and a naive-hashing comparison. The React client draws the ring (or naive buckets), animates key movement, and lets you preview topology changes before applying them.
State lives in memory on the server. Restarting the API clears nodes and keys.
Imagine the hash space as a circle (the hash ring), from 0 up to a large maximum, then wrapping around.
- Nodes (servers) are hashed onto the circle.
- Keys (data items) are hashed onto the same circle.
- A key belongs to the first node encountered when walking clockwise from the key’s position. If nothing is found before the wrap, ownership goes to the first node on the ring.
When you add a node, it takes over only the arc that previously belonged to its clockwise successor. Other keys stay put.
When you remove a node, its keys move to the next clockwise node. The rest of the ring is unchanged.
Virtual nodes (implemented here) place several positions per physical server (name#0, name#1, …). That splits each server’s share of the circle into smaller arcs, which usually improves load balance.
flowchart LR
K["hash(key)"] --> P["Position on ring"]
P --> W["Walk clockwise"]
W --> V["First virtual node"]
V --> N["Owning physical node"]
Traditional (naive) placement is:
node = hash(key) % N
N is the number of servers. Changing N (add or remove a machine) changes the modulus, so most keys can move. That is expensive: cache misses, data copies, and hot spots during rebalance.
Consistent hashing maps both keys and nodes into a shared circular space. Only keys in the affected arcs move—typically a much smaller fraction, especially with virtual nodes.
This visualizer lets you switch among Consistent Hashing, Naive Hashing, and Side-by-Side so you can see that difference on the same node and key set.
Verified in the current codebase:
- Interactive SVG hash ring with physical nodes, virtual nodes, and keys
- Add / remove named physical nodes
- Add named keys and generate batches of random keys (up to 5,000 per request)
- Key-to-node mapping list with tooltips
- Virtual nodes per physical node, adjustable from 1–100 (default 3)
- Clockwise lookup tracer (hash → position → walk → virtual node → physical node)
- Migration measurement and animation when topology changes
- Dry-run preview before applying add/remove node or virtual-node-count changes (consistent-hashing mode)
- Statistics: counts, load variance, balance score, largest/smallest owner, migration percent
- Distribution histogram of keys per node
- Comparison modes: consistent, naive (
hash % N), side-by-side - Naive bucket view with numbered slots
- Event log, reduced-motion support (OS preference + optional override), skip-to-controls link
- In-memory reset of the entire ring
- You add physical nodes. Each node is hashed;
Vvirtual nodes are created (name#index) and placed on the ring. - You add keys. Each key is hashed to a 32-bit position.
- The server sorts virtual nodes by hash and assigns every key to the successor virtual node (clockwise), then to that vnode’s physical owner.
- In consistent mode, add/remove node and vnode-count changes open a preview (before/after stats and migration). Confirming applies the mutation.
- In naive mode, physical nodes are sorted by name and each key goes to
hash(key) % N. Virtual-node count does not apply. - Side-by-side keeps one shared topology and shows both assignment algorithms, including how many keys each algorithm moved.
- Lookup tracer requests step-by-step clockwise walk data and can play, pause, step, or replay the walk on the ring.
consistent-hashing-visualizer/
├── client/ React + Vite UI
│ ├── index.html
│ ├── vite.config.js
│ ├── src/
│ │ ├── App.jsx Controls, API calls, layout
│ │ ├── main.jsx
│ │ ├── index.css
│ │ ├── components/ Ring, buckets, stats, tracer, preview, …
│ │ ├── hooks/ Reduced-motion preference
│ │ └── utils/ Ownership arcs, motion, colors, tests
│ └── public/
└── server/ Express API
├── index.js HTTP server, CORS, JSON
├── routes/ring.js REST endpoints and in-memory state
├── core/
│ ├── hash.js MD5 → 32-bit hash
│ ├── ring.js Ring, vnodes, lookup, preview, generate
│ ├── comparison.js Naive hashing + response shape
│ ├── migration.js Assignment diffs
│ ├── stats.js Load statistics
│ └── validators.js Ring invariants (used by tests)
└── test/ Node.js test runner suites
There is no persistence layer. Ring state is held in memory on the Express process.
Hash (Node crypto, MD5, first 8 hex digits parsed as a 32-bit integer):
hash(s) = parseInt(md5(s).hex[0..7], 16)
Virtual nodes for physical name N and count V:
vnode i = { id: "N#i", hash: hash("N#i") } for i in 0 .. V-1
Successor (clockwise owner):
sorted = virtual nodes sorted by hash ascending
findOwner(keyHash):
if sorted is empty: return null
return first vnode with hash >= keyHash
or sorted[0] if none (wrap-around)
Naive comparison (physical nodes sorted by name):
index = keyHash % nodeCount
owner = sortedPhysicalNodes[index]
Lookup steps on the server match findOwner and are rejected if they disagree with the stored assignment.
| Concern | Implementation |
|---|---|
| Hashing | MD5 truncated to 32 bits (server/core/hash.js) |
| Ring | Array of virtual nodes, sorted by hash, linear successor scan (Array.find) |
| Nodes | { id, name, hash, virtualNodes[] } |
| Keys | { key, hash, assignedNode, assignedVirtualNode } |
| Virtual nodes | name#index; count 1–100, default 3 |
| State | Module-level arrays on the Express router (not a database) |
| Migration | Diff on assignedVirtualNode.id; new keys are not counted as topology migrations |
| Preview | Deep clone (structuredClone), mutate clone, redistribute, return before/after |
| Visualization | SVG ring; angle = (hash / 0xffffffff) * 2π; Framer Motion + rAF walks |
| Client state | React useState / useRef; Axios to VITE_API_URL or http://localhost:5000/api/ring |
| Tests | Node.js built-in node --test on server core and client utils |
Successor search is linear, not a binary search or tree. Do not treat lookups as O(log V) unless that data structure is added later.
| Technology | Purpose |
|---|---|
| React 19 | UI |
| Vite 7 | Client bundler and dev server |
| Tailwind CSS 4 | Styling |
| Axios | HTTP client |
| Framer Motion | Ring / key motion |
| Express 5 | REST API |
| cors | Cross-origin requests from the Vite app |
| dotenv | Optional PORT loading |
Node.js crypto |
MD5 hashing and random key names |
| Node.js test runner | Unit / golden tests |
Requires Node.js and npm. The client and server are separate packages (no root package.json).
git clone https://github.com/devs-diaries/Consistent_Hashing_Visualizer.git
cd Consistent_Hashing_VisualizerAPI (default port 5000):
cd server
npm install
npm run devUse npm start for node index.js without nodemon.
UI (Vite; typically http://localhost:5173):
cd client
npm install
npm run devRun the two processes in separate terminals. Point the client at the API with VITE_API_URL if the API is not on localhost:5000.
There is no .env.example. Variables that appear in code:
| Variable | Where | Purpose |
|---|---|---|
PORT |
Server | HTTP port (default 5000) |
VITE_API_URL |
Client (build/dev) | Ring API base URL (default http://localhost:5000/api/ring) |
Neither is required for local development with the defaults. Do not commit secrets; this app does not use API keys.
- Start the server, then the client, and open the Vite URL.
- Choose Consistent Hashing, Naive Hashing, or Side-by-Side.
- Enter a node name and click Add Node. In consistent mode, review the preview, then apply.
- Add keys by name, or generate a batch (
+10/+50/+100/+500shortcuts). - Watch placement on the ring (or naive buckets) and in Node-Key Mapping.
- Adjust Virtual Nodes per Physical Node (consistent / side-by-side) and compare load in the stats panel and histogram.
- Add or remove a node and observe migration percent, flying keys (unless reduced motion), and the migration summary.
- In consistent mode, type a key into the lookup tracer and Trace the clockwise walk (play / step / replay).
- Reset clears nodes, keys, vnode count, and mode back to consistent hashing.
Conceptual ring (clockwise from a key):
Node A (vnodes on the circle)
|
Key 1 |
|
Node C --------+-------- Node B
If hash("user:42") sits just after Node C and before Node A, the successor is Node A. Adding Node B on that same arc steals only keys in the new interval; keys already past Node B toward Node A stay on A.
Naive hashing would instead number nodes alphabetically and assign hash % 3 (or % 4 after the add), which can reshuffle most keys.
Let N = physical nodes, V = virtual nodes (N × vnodeCount), K = keys.
| Operation | Time (this implementation) | Space |
|---|---|---|
| Hash a string | O(length of string) | O(1) extra |
| Build sorted ring | O(V log V) | O(V) |
| Find owner | O(V) linear scan | O(1) |
| Insert / delete node | Rebuild ring + reassign all keys: O(V log V + K V) | O(N + V + K) |
| Insert key | O(V) to assign | O(1) amortized in the array |
| Redistribute all keys | O(V log V + K V) | O(K) |
| Migration diff | O(K) | O(K) |
| Preview | Clone + same as topology change | Extra clone of nodes/keys |
| Stats | O(N + K) | O(N) |
Virtual nodes increase V, which improves balance in practice but makes sort and successor scans more expensive. Key enter animations on the ring are skipped when K > 80 to keep the UI responsive.
- Circular ring matches the successor rule and makes wrap-around visible.
- MD5 truncated to 32 bits is deterministic and cheap for a visualizer; it is not a cryptographic design for production clusters.
- Linear successor keeps the code easy to test and to explain; the ring sizes here are pedagogical, not millions of vnodes.
- Virtual nodes are first-class so students can see balance vs. vnode count (1–100).
- In-memory Express state avoids a database for a demo; all clients share one ring on that server process.
- Preview + apply in consistent mode separates “what would move?” from committing the change.
- Parallel naive key list uses the same names/hashes so comparison is fair; synthetic vnode ids
naive:namereuse the migration helper. - Reduced motion honors
prefers-reduced-motionand an optional persisted toggle so education still works without long animations.
Handled in the API and/or UI:
| Case | Behavior |
|---|---|
| Empty ring | Keys exist but assignedNode / assignedVirtualNode are null; lookup is rejected |
| Single node | All keys assign to that node (and its vnodes) |
| Duplicate node name | 409 — node already exists |
| Duplicate key name | 409 — key already exists |
| Remove missing node | 404 |
| Remove last node | Allowed; remaining keys become unassigned |
| Invalid vnode count | 400 — must be integer 1–100 |
| Vnode count in naive mode | 400 — not applicable |
| Generate count | Integer 1–5000; unique k_<n>_<hex> names |
| Hash wrap-around | Owner is the first vnode in sorted order |
| Lookup vs assignment mismatch | 500 if tracer steps disagree with stored owner |
| Preview outside consistent mode | 400 |
| Mode switch | Remaps with the same keys/nodes; no migration animation |
Equal vnode hashes are ordered by JavaScript’s stable sort after the numeric comparison; successor still uses the first hash >= keyHash.
cd server && npm test
cd client && npm testServer tests cover ring golden cases, stats, migration, preview, and naive comparison. Client tests cover histogram scaling, ring ownership helpers, migration motion, and lookup visuals.
These are not implemented. They follow from how the visualizer works today (in-memory ring, linear successor scan, truncated MD5, shared process state).
- Faster successor lookup — replace the linear
Array.findwith binary search on the sorted ring (trueO(log V)lookups). - Optional hash functions — let users compare MD5, SHA-1, or a simple FNV-style hash so they can see clustering vs. spread.
- Weighted nodes — give a physical node extra virtual nodes to model a larger machine, instead of the same vnode count for every server.
- Replication factor — show N clockwise successors (like a Dynamo-style preference list), not only the primary owner.
- Larger-scale demos — tens of thousands of keys with sampled animation, plus a table of expected vs. observed migration percent when N changes.
- Failure / recovery walkthrough — a guided “node dies, then returns” sequence with freeze-frame ownership arcs.
- Export / import scenarios — save a node+key setup as JSON so lessons can be replayed.
- Per-client sessions — isolate rings so concurrent users (or tabs) do not share one in-memory cluster.
- Persistence — optional store for sessions if the visualizer is hosted; the API currently resets on process restart.
- Documented deploy + screenshots — a live URL and short GIFs of add-node, naive vs. consistent, and lookup trace.
- A project
LICENSEfile —server/package.jsonstill carries npm’s defaultISCstring; a real license should be chosen explicitly.
- Consistent hashing — Wikipedia overview
- Dynamo: Amazon’s Highly Available Key-value Store — virtual nodes in a production system
- Ketama — widely cited consistent-hashing implementation for memcached
- Fork or branch from
main. - Keep ring assignment logic in
server/coreand visualization inclient/src. - Add or update
node --testfiles when you change hashing, migration, or ownership. - Open a pull request with a short description of the behavior change.
GitHub repository: devs-diaries/Consistent_Hashing_Visualizer
Organization: devs-diaries
Contributor: Aishwary_Malviya