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.