The fastest request is one you don’t make. Caching trades storage for speed, serving precomputed results instead of recalculating them. But caching done wrong is worse than no caching—stale data, inconsistencies, and debugging nightmares.

When to Cache

Cache when:

  • Data is read more often than written
  • Computing the result is expensive
  • Slight staleness is acceptable
  • The same data is requested repeatedly

Don’t cache when:

  • Data changes constantly
  • Every request needs fresh data
  • Storage cost exceeds compute savings
  • Cache invalidation is harder than recomputation

Cache Placement

Client-Side Cache

Browser cache, mobile app cache, CDN edge cache:

1
2
Cache-Control: public, max-age=3600
ETag: "abc123"

Best for: Static assets, public content, data that rarely changes.

Application-Level Cache

In-memory cache within your application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300 });

async function getUser(id) {
  const cached = cache.get(`user:${id}`);
  if (cached) return cached;
  
  const user = await db.users.findById(id);
  cache.set(`user:${id}`, user);
  return user;
}

Best for: Frequently accessed data, session data, computed results. Limitation: Not shared across instances.

Distributed Cache

Shared cache across application instances (Redis, Memcached):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const Redis = require('ioredis');
const redis = new Redis();

async function getUser(id) {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);
  
  const user = await db.users.findById(id);
  await redis.setex(`user:${id}`, 300, JSON.stringify(user));
  return user;
}

Best for: Shared state, session storage, rate limiting, leaderboards.

Database Query Cache

Let the database cache query results:

1
2
3
4
5
6
7
8
-- MySQL query cache (deprecated in 8.0, but concept applies)
SELECT SQL_CACHE * FROM users WHERE id = 123;

-- PostgreSQL: use materialized views for expensive aggregations
CREATE MATERIALIZED VIEW daily_stats AS
SELECT date, COUNT(*) as total FROM orders GROUP BY date;

REFRESH MATERIALIZED VIEW daily_stats;

Caching Patterns

Cache-Aside (Lazy Loading)

Application manages cache explicitly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
async function getData(key) {
  // 1. Check cache
  let data = await cache.get(key);
  if (data) return data;
  
  // 2. Cache miss: load from source
  data = await loadFromDatabase(key);
  
  // 3. Populate cache
  await cache.set(key, data, TTL);
  
  return data;
}

Pros: Only caches what’s actually used. Cons: Cache miss = slow first request.

Write-Through

Write to cache and database simultaneously:

1
2
3
4
5
6
7
async function saveData(key, data) {
  // Write to both
  await Promise.all([
    cache.set(key, data),
    db.save(key, data),
  ]);
}

Pros: Cache always consistent with database. Cons: Write latency increased.

Write-Behind (Write-Back)

Write to cache immediately, sync to database asynchronously:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
async function saveData(key, data) {
  // Write to cache immediately
  await cache.set(key, data);
  
  // Queue database write
  await writeQueue.add({ key, data });
}

// Background worker
writeQueue.process(async (job) => {
  await db.save(job.data.key, job.data.data);
});

Pros: Fast writes, batching opportunities. Cons: Risk of data loss if cache fails before sync.

Read-Through

Cache handles loading transparently:

1
2
3
4
5
6
7
8
// Redis with a loader function (conceptual)
const cache = new ReadThroughCache({
  loader: async (key) => db.findById(key),
  ttl: 300,
});

// Application just calls get()
const user = await cache.get('user:123');

Cache Invalidation

“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton

Time-Based Expiration (TTL)

Simplest approach—data expires after a set time:

1
await redis.setex('user:123', 300, userData);  // Expires in 5 minutes

Pros: Simple, automatic cleanup. Cons: Data can be stale until expiration.

Event-Based Invalidation

Invalidate when source data changes:

1
2
3
4
5
6
7
8
// When user is updated
async function updateUser(id, data) {
  await db.users.update(id, data);
  await cache.del(`user:${id}`);  // Invalidate cache
}

// Or publish invalidation event
await eventBus.publish('user.updated', { userId: id });

Pros: Cache always fresh after writes. Cons: Must track all write paths.

Tag-Based Invalidation

Group related cache entries:

1
2
3
4
5
6
// Cache with tags
await cache.set('user:123', userData, { tags: ['users', 'user:123'] });
await cache.set('user:123:posts', posts, { tags: ['users', 'user:123', 'posts'] });

// Invalidate all entries for a user
await cache.invalidateTag('user:123');

Version-Based Keys

Include version in cache key:

1
2
3
4
const version = await getSchemaVersion();  // or hash of dependencies
const key = `user:${id}:v${version}`;

// Old versions naturally expire, no explicit invalidation needed

Cache Stampede Prevention

When cache expires, multiple requests might simultaneously hit the database:

Cacheexpires100requests100databasequeriesdatabaseverwhelmed

Locking

Only one request refreshes cache:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
async function getWithLock(key) {
  let data = await cache.get(key);
  if (data) return data;
  
  // Try to acquire lock
  const lockKey = `lock:${key}`;
  const acquired = await cache.setnx(lockKey, '1', 10);  // 10s lock
  
  if (acquired) {
    // We got the lock, refresh cache
    data = await loadFromDatabase(key);
    await cache.set(key, data, TTL);
    await cache.del(lockKey);
  } else {
    // Someone else is refreshing, wait and retry
    await sleep(100);
    return getWithLock(key);
  }
  
  return data;
}

Probabilistic Early Expiration

Refresh cache before it expires:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
async function getWithEarlyRefresh(key) {
  const { data, ttl } = await cache.getWithTTL(key);
  
  if (data) {
    // Probabilistically refresh if close to expiration
    if (ttl < 60 && Math.random() < 0.1) {
      // 10% chance to refresh if <60s remaining
      refreshInBackground(key);
    }
    return data;
  }
  
  return loadAndCache(key);
}

Caching Pitfalls

Caching Nulls

Don’t let cache misses for non-existent data hammer your database:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
async function getUser(id) {
  const cached = await cache.get(`user:${id}`);
  if (cached === 'NULL_MARKER') return null;  // Cached non-existence
  if (cached) return JSON.parse(cached);
  
  const user = await db.users.findById(id);
  if (user) {
    await cache.setex(`user:${id}`, 300, JSON.stringify(user));
  } else {
    await cache.setex(`user:${id}`, 60, 'NULL_MARKER');  // Cache the miss
  }
  return user;
}

Thundering Herd on Cold Start

When cache is empty (deployment, cache flush), everything hits database:

Solution: Warm the cache before routing traffic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
async function warmCache() {
  const popularItems = await db.getPopularItems(1000);
  for (const item of popularItems) {
    await cache.set(`item:${item.id}`, item);
  }
}

// Call during startup, before accepting traffic
await warmCache();
server.listen(8080);

Serialization Overhead

JSON serialization has costs:

1
2
3
4
5
6
7
8
// Slow: serialize/deserialize every time
await cache.set(key, JSON.stringify(largeObject));
const data = JSON.parse(await cache.get(key));

// Faster: use binary serialization (msgpack, protobuf)
const msgpack = require('msgpack-lite');
await cache.set(key, msgpack.encode(largeObject));
const data = msgpack.decode(await cache.get(key));

Monitoring Cache Health

Track these metrics:

  • Hit rate: % of requests served from cache (target: >90%)
  • Miss rate: % of cache misses
  • Latency: Cache response time (should be <10ms)
  • Memory usage: Are you approaching limits?
  • Eviction rate: Is cache too small?
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Instrument your cache
async function get(key) {
  const start = Date.now();
  const value = await cache.get(key);
  const duration = Date.now() - start;
  
  metrics.histogram('cache.latency', duration);
  metrics.increment(value ? 'cache.hit' : 'cache.miss');
  
  return value;
}

The Mental Model

Think of caching like keeping frequently used tools on your workbench instead of in the storage room:

  • Workbench (cache): Fast access, limited space
  • Storage room (database): Slower access, unlimited space
  • TTL: How long before you return a tool to storage
  • Invalidation: Someone moved the tool, clear your workbench spot

The goal isn’t to cache everything—it’s to cache the right things, for the right duration, with the right invalidation strategy.

A well-tuned cache is invisible. A poorly-tuned cache is a constant source of bugs.