BugForge
All lessons
Node.js · 11/19 6 min

Caching without stale bugs

Cache keys, TTL units, invalidation on writes, returning copies, and an LRU with Map.

Caching bugs look like "the data is wrong sometimes". There are only a few root causes.

1. The key is missing an input

const key = `products`;                                   // ❌ page 2 returns page 1
const key = `products:${page}:${limit}:${req.user.id}`;   // ✅ everything that changes the answer

2. Writes don't invalidate

After POST/PUT/DELETE, delete (or update) every key that could contain that record. For list endpoints, a version counter is simpler than tracking keys:

let version = 0;
const key = `products:v${version}:${page}`;
// on any write
version++;

3. TTL math

const fresh = Date.now() - entry.at < ttlMs;   // same units on both sides

Seconds vs ms here means entries never expire (or always do). Also decide: is "exactly at TTL" expired? Tests usually say yes, so use <.

4. Callers mutate the cached object

const user = cache.get(id);
user.name = "x";               // also changed what the cache holds
return structuredClone(entry.value);   // hand out copies

5. Caching failures

Don't store errors or empty results from a failed call, or one blip is served for the whole TTL.

LRU with Map

Map remembers insertion order, so the first key is the least recently used one:

get(k) {
  if (!this.map.has(k)) return undefined;
  const v = this.map.get(k);
  this.map.delete(k); this.map.set(k, v);   // move to "most recent"
  return v;
}
set(k, v) {
  this.map.delete(k);
  this.map.set(k, v);
  if (this.map.size > this.cap) this.map.delete(this.map.keys().next().value);
}

Common slips: forgetting to refresh on get, evicting before inserting (off by one on capacity), and if (!value) treating a cached 0 or "" as a miss.

Next in Node.js
Reading Jest failures fast