You Might Also Like
LRU Cache Visualizer — Free HTML CSS JS Snippet
LRU Cache Visualizer · Dashboards · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
LRU Cache Visualizer — Doubly Linked List + Hash Map Animating True O(1) get/put and Eviction

Most "LRU cache" demos on the web fake the algorithm with an array and indexOf/splice, which is O(n) per operation and quietly teaches the wrong lesson about why real LRU caches are fast. This snippet implements the actual textbook data structure — a doubly linked list ordered by recency plus a hash map from key to list node — so every get and put genuinely runs in O(1), and the animation you see on screen is a direct visualization of real pointer surgery, not a reshuffled array pretending to be one.
Why a linked list instead of an array
An array-based "LRU" typically does arr.findIndex(k) (O(n) scan) followed by arr.splice() (O(n) shift) on every single access, which defeats the entire purpose of caching — a lookup that costs O(n) is barely better than not caching at all once the array grows. A doubly linked list solves this because moving a node to the front only requires rewriting four pointers (the moved node's neighbors' next/prev, and the moved node's own next/prev), regardless of how many items are in the list. Combined with a hash map that gives instant node lookup by key, both operations become genuinely O(1): this.map.get(key) finds the node instantly, and _remove/_insertFront relink it instantly, with no scanning of any kind.
The two sentinel nodes that remove every edge case
this.head and this.tail are permanent dummy nodes that are never evicted and never returned to the caller — head.next is always the current most-recently-used real node, and tail.prev is always the current least-recently-used real node. This sentinel-node pattern is the detail that most hand-rolled LRU implementations get wrong: without it, _remove() and _insertFront() need special-case branches for "the list is empty," "removing the only node," or "inserting into an empty list," because there is no real neighbor to relink against. With sentinels, this.head.next = this.tail on construction means _remove and _insertFront are four-line functions with zero conditionals — they always have a real .prev and .next object to write to, even in an empty cache.
get(key): unlink and re-insert, not "find and swap"
get(key) first checks this.map.has(key); a miss returns immediately with no list mutation at all. On a hit, the node is unlinked from its current position with _remove(node) and immediately relinked at the front with _insertFront(node) — the same node object, just re-pointed. On screen this plays out as the accessed box's left CSS value animating across to the leftmost (MRU) slot while every box that used to sit ahead of it shifts one slot to the right, all driven by transition: left 0.38s rather than a JavaScript animation loop, since a single property tween is all a slot reorder needs.
put(key, value): three distinct paths, three distinct animations
put() branches into exactly the three cases a real LRU cache has to handle. If the key already exists, its value is overwritten and the existing node is moved to the front exactly like a get hit — visualized identically, plus a brief green pulse to distinguish "value changed" from "just accessed." If the key is new and the cache has spare capacity, a fresh node is linked in at the front and its box animates in from a scaled-down, transparent state. If the key is new and the cache is already at capacity, the code reads this.tail.prev — the LRU node — evicts it with _remove plus a map.delete, and only then inserts the new node at the front; the evicted box animates out (a red fade-and-shrink) before the remaining boxes shift to make room, so eviction is never silent or instantaneous.
Why eviction is a pointer read, not a search
Because the list is kept sorted by recency at all times (every access moves its node to the front), the least-recently-used item is always sitting at this.tail.prev — no scan is ever needed to find "the oldest one." This is the second half of why the whole structure is O(1): insertion and lookup are fast because of the hash map, and eviction is fast because the linked list's own ordering invariant means the eviction candidate is always exactly one pointer dereference away.
Layout by index, not full FLIP, for a simpler correct animation
Rather than a general FLIP (First-Last-Invert-Play) animation library, this snippet keeps positioning simple and honest: layout() walks the linked list front-to-back after every mutation and sets each existing box's left to index * SLOT_WIDTH. Because the CSS transition is declared on left itself, the browser animates the change automatically — the JavaScript never computes a transform matrix or an intermediate frame, it just asks "where does each node belong now" and lets the browser's own compositor interpolate the movement.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Hand the LRUCache class to an AI assistant like Claude and ask it to trace exactly which four pointers change during a single get() hit, or why the sentinel head/tail nodes remove the need for any empty-list special case — walking through one _remove/_insertFront pair by hand is the fastest way to actually internalize the mechanism. Worth asking for as extensions: a capacity slider that live-resizes the cache, a hit-rate counter that tracks hits versus misses over a session, or an LFU (least-frequently-used) mode alongside LRU to compare eviction strategies on the same sequence of operations.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build an animated LRU (Least Recently Used) cache visualizer in plain HTML, CSS, and JavaScript, no libraries or frameworks.
Requirements:
- Implement the cache as a genuine doubly linked list (nodes with real prev/next pointers) combined with a hash map from key to node — do not fake the ordering with a plain array and indexOf/splice, since the entire point is demonstrating true O(1) get/put.
- Use two permanent sentinel nodes (head and tail) that are never evicted, so insertion and removal functions never need special-case branches for an empty or single-item list.
- A fixed-capacity row of visual slots (e.g. 4), ordered left-to-right from most-recently-used to least-recently-used, with empty capacity shown as dashed placeholder slots rather than left blank.
- get(key): on a hit, unlink the corresponding node from its current position and re-link it at the most-recently-used front, animating its box sliding to the front slot while other boxes shift to fill the gap; on a miss, show a clear no-op result with no animation.
- put(key, value): if the key exists, update its value and move it to the front like a hit (with a distinct visual cue such as a brief color pulse so "updated" reads differently from "just accessed"); if the key is new and there is spare capacity, animate the new box scaling/fading in at the front; if the key is new and the cache is full, first animate the current least-recently-used box (at the tail) fading and shrinking out, then animate the new box sliding in at the front.
- Read the eviction candidate directly from the tail-side sentinel's neighbor rather than scanning the list, since correctness of that O(1) read is the core teaching point.
- Provide key/value text inputs with get and put buttons, a random-operation button that fuzzes the cache with random get/put calls, and a scrolling operation log recording every call and its outcome (hit, miss, inserted, evicted, updated).Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Insert a few keys with put(key, value)Type a key like A and a value like 1, then click put. The new box slides in and animates to the leftmost, most-recently-used slot. Repeat with B, C, D to fill all 4 slots.
- 2Access an existing key with get(key)Type an existing key and click get. If found, its box animates across to the MRU slot and briefly pulses green — the status line reports the O(1) hit and confirms the linked-list splice that just happened.
- 3Try get() on a key that was never insertedThe status line reports a clean MISS with no animation, since a hash-map lookup that fails does not touch the linked list at all — this is the fast-fail path.
- 4Fill the cache past capacity 4Insert a 5th distinct key. Watch the box in the rightmost (least-recently-used) slot fade and shrink out in red before the new key slides in at the front — that is the real eviction path, reading straight from the tail pointer.
- 5Access an older key, then overflow againget() an older key to move it back to the MRU end, then insert two more new keys. Notice the key you just accessed survives longer than keys you never touched, since eviction always targets whatever currently sits at the tail.
- 6Click Random op to fuzz the cache automaticallyEach click performs either a random get on an existing key or a random put with a new key/value pair, useful for watching the eviction order settle into a pattern over many operations.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Yes. Move the LRUCache class into a plain utility module — it has no DOM dependency at all, it only manipulates plain JS objects. Keep the cache instance in a ref (React), a non-reactive plain variable (Vue, mutated outside reactivity), or a class field (Angular) rather than component state, since its internal pointer structure should not be deep-cloned or made reactive. Trigger doGet/doPut from click handlers and drive the visual boxes off cache.order() after each mutation. The only cleanup concern is the chain of await wait(ms) calls inside doGet/doPut: guard each one with a "still mounted" flag checked before touching the DOM, and set that flag false in the component unmount hook (useEffect cleanup, onUnmounted, ngOnDestroy) so an in-flight animation does not write to a removed node.
Array.indexOf() is a linear scan, O(n), and Array.splice() to remove or reinsert an element is also O(n) because every following element has to shift index. Doing both on every single get and put means an array-based cache degrades linearly as it grows, which defeats the point of caching for lookup speed. A doubly linked list plus hash map avoids both scans entirely: the map gives instant node lookup, and moving a node within a linked list is a fixed number of pointer writes no matter how large the list is.
Without sentinels, every insert/remove function needs extra conditional branches for edge cases like an empty list or a single-node list, because there is no guaranteed real neighbor object to write .next/.prev onto. With permanent head and tail dummy nodes that are always present, _remove() and _insertFront() can unconditionally assume a valid .prev and .next exist on every node, which is what keeps them at a clean four lines each with zero special-casing.
The existing node's value is overwritten in place and the node is unlinked and re-inserted at the MRU front, exactly like a get() hit — this snippet gives it a brief green pulse in addition to the slide so you can tell "value updated" apart from "just read." Critically, updating an existing key never triggers eviction, since the cache's total item count does not change.
Because every get and put keeps the list sorted by recency (accessed or inserted nodes always move to the front), the node at tail.prev is, by construction, always the one that has gone the longest without being touched. Eviction never scans anything — it just reads this.tail.prev.key directly, which is why eviction is O(1) exactly like get and put.