DEV Community

Rich Polo
Rich Polo

Posted on

Build an LRU Cache in 25 Lines of JavaScript (Using Nothing but Map)

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 };
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
onelifevibe profile image
OneLifeVibe •

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?