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

  • get deletes and re-inserts the key, moving it to the "newest" end.
  • set inserts (or refreshes) the key, then evicts map.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 in get.
  • 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.