Caching Strategies
Node.js Advanced
Chapter 5 of 8 · Caching Strategies
Caching Strategies
A cache is a faster store that sits in front of a slower one. The slow store might be a database, an external API, or an expensive computation. Done well, caching cuts response times from hundreds of milliseconds to single-digit milliseconds and lets your database handle a fraction of the load. Done badly, it serves stale data nobody trusts, wastes memory on keys that never get read twice, or collapses under a thundering herd the moment the cache goes cold. This chapter covers the full picture: in-process caches, Redis patterns, cache-aside, invalidation strategies, and stampede prevention.
Why (and When) to Cache
| Cache tier | Latency | Scope | Survives restart? | Best for |
|---|---|---|---|---|
| In-process Map/LRU | <1 µs | Single process only | No | Expensive computations, config, rarely-changing reference data |
| Redis | ~0.5–2 ms | Shared across all processes/servers | Optional (AOF/RDB) | Session data, API responses, rate limiting, distributed locks |
| HTTP cache headers | 0 ms (client-side) | Individual client / CDN | Yes (CDN) | Static assets, public API responses that don't vary per user |
Don't cache everything. Cache only when: (1) the data is read far more than it's written, (2) the source is meaningfully slow, and (3) serving briefly stale data is acceptable or you have a reliable invalidation strategy.
In-Process Caching — TTL Map
LRU Cache — Bounded by Entry Count
A TTL cache evicts by age. An LRU (Least Recently Used) cache evicts the entry that was accessed furthest in the past when the cache hits its maximum size. The two are often combined: bounded by both count and TTL.
Cache-Aside Pattern (Lazy Loading)
Cache-aside is the most common pattern: the application code is responsible for populating and invalidating the cache. The cache never talks to the database directly.
Redis — Shared Cache Across Processes
Redis Cache-Aside (Distributed)
Cache Invalidation Strategies
TTL-Based (Time to Live)
Every key expires after N seconds. The simplest strategy — no explicit invalidation code needed. Trade-off: data can be stale for up to TTL seconds after a write.
Tune TTL to your staleness tolerance. EX 300 for user profiles; EX 5 for live inventory counts.
Event-Based (Active Invalidation)
When a record is written, explicitly delete its cache key: redis.del(`user:${id}`). The next read repopulates from the DB.
Stronger consistency than TTL alone — the window between write and stale eviction drops to near-zero. Requires discipline: every write path must also invalidate.
Cache Versioning (Namespace Bumping)
Store a version number: user:v3:42. When the schema or logic changes, bump the version prefix and all old keys become orphaned (and expire naturally via TTL).
Useful for mass invalidations without a FLUSHDB — just bump the global version.
Tag-Based Invalidation
Tag each cached value with logical groups (tag:product:99). When product 99 changes, delete every key in its tag set. Redis Sets work well for this: SADD tag:product:99 "key1" "key2" → on update, SMEMBERS + DEL all members.
Cache Stampede (Thundering Herd)
A cache stampede happens when a popular key expires and dozens of requests all miss simultaneously — they all query the database in parallel, pounding it with identical queries. The fix is to ensure only one request populates the cache while others wait or get a slightly stale value.
HTTP Cache Headers
⚠️ Cache Key Design — Get This Wrong and You Cache the Wrong User's Data
Cache keys must include every variable that affects the result. A route that returns different data per user must include the user ID in the key: user:42:orders not just orders. A route that varies by language must include locale: products:en:42.
Never use raw user input directly as a cache key — sanitise it first. A key like `search:${userInput}` can be poisoned with special characters, or explode the key space with unique garbage values that never get a second hit.
💡 Key Naming Convention
Use entity:id:field format: user:42, user:42:orders, product:99:stock. Colons are Redis's conventional namespace separator — they don't have special meaning to Redis but make redis-cli --scan --pattern "user:*" queries readable.
Prefix with your app name in shared Redis instances: myapp:user:42. Avoid key collisions between services.
Coding Challenges
Challenge 1 — LRU Cache from Scratch
Implement an LRUCache class backed by a doubly-linked list and a Map (no external packages). It must support: get(key) (O(1), moves accessed node to head), set(key, value, ttlMs) (O(1), evicts LRU tail when over capacity, respects per-entry TTL), delete(key) (O(1)), and a read-only size property. Write tests proving: LRU eviction order is correct, TTL expiry removes entries, and all operations are O(1).
Challenge 2 — Redis Cache Wrapper with Stats
Build a RedisCache class wrapping ioredis that adds: getOrSet(key, fetcher, ttlSec) implementing cache-aside, a hit/miss counter accessible via stats() returning { hits, misses, hitRate }, and invalidate(...keys) that deletes multiple keys in a single pipeline call. Write tests using ioredis-mock (or a hand-rolled mock) that verify: a cold get calls the fetcher exactly once, a warm get skips the fetcher, stats accumulate correctly, and invalidate clears the right keys.
Challenge 3 — Stampede-Proof Cache
Build stampedeSafeGet(redis, key, fetcher, ttlSec) that prevents thundering herd using a Redis lock (SET NX EX). Prove it works by simulating 20 concurrent callers all requesting the same cold key simultaneously — assert that the underlying fetcher is called exactly once no matter how many concurrent requests arrive. Use ioredis-mock for Redis and control timing with jest.useFakeTimers() or a manual promise barrier. Also handle the edge case where the lock holder crashes (lock auto-expires) so waiters don't block forever.