Every time you memoize an API call or cache rendered fragments, you face the same question: what do I throw away when memory fills up? The classic answer is LRU — Least Recently Used: evict the entry nobody has touched for the longest time.
Textbook LRU caches use a hash map plus a doubly linked list. In JavaScript you can skip the linked list entirely, because Map remembers insertion order. The first key returned by map.keys() is always the oldest one. If we re-insert a key every time it is read, the front of the Map is automatically the least recently used entry.
The implementation
class LRUCache {
constructor(capacity) {
if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('capacity must be a positive integer');
this.capacity = capacity;
this.map = new Map();
}
get(key) {
if (!this.map.has(key)) return undefined;
const value = this.map.get(key);
this.map.delete(key); // move to the "most recent" end
this.map.set(key, value);
return value;
}
set(key, value) {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
this.map.delete(oldest);
}
return this;
}
get size() { return this.map.size; }
}
module.exports = { LRUCache };
How it works
-
getdeletes and re-inserts the key, moving it to the "newest" end. -
setinserts (or refreshes) the key, then evictsmap.keys().next().value— the oldest key — if we are over capacity. - Both operations are O(1) on average.
Testing it
const assert = require('node:assert');
const { LRUCache } = require('./lru');
const c = new LRUCache(2);
c.set('a', 1).set('b', 2);
assert.strictEqual(c.get('a'), 1); // a is now most recent
c.set('c', 3); // evicts b
assert.strictEqual(c.get('b'), undefined);
assert.strictEqual(c.get('a'), 1);
assert.strictEqual(c.get('c'), 3);
assert.strictEqual(c.size, 2);
assert.throws(() => new LRUCache(0), RangeError);
console.log('all LRU tests passed');
Run it with node lru.test.js — output: all LRU tests passed (verified on Node 18+, no dependencies; uses only node:assert).
When to use something else
- Need time-based expiry? Store
{ value, expiresAt }and check it inget. - Caching in a browser across reloads? Map lives in memory only; look at IndexedDB.
- Huge caches with heavy traffic? A battle-tested package will add metrics and size-based limits.
But for a quick memoization layer, this 25-line class is often all you need. Small, readable, and easy to explain in a code review.
Top comments (1)
This is a neat reminder that Map remembering insertion order removes the whole doubly linked list from the textbook version. The delete-and-re-insert move in get is the part that makes the trick click — it is a tiny operation, but it is what keeps the front of the Map as the least recently used entry. I also appreciated the honest boundaries at the end, especially that a Map lives in memory only, so this is a memoization layer rather than a persistent cache. Did you find the capacity check in the constructor catching a real bug in practice, or was it mainly defensive?