Symptom: the database becomes the bottleneck
The system is slow. You open monitoring and see:
- query count growing linearly with user count
- the same query running hundreds of times per second with unchanged results
- database CPU sitting at 80–90%
- API response time climbing through peak hours
This is a sign that the system is pushing every request down to the database, including data that never changes.
Caching solves this by storing computed results at a faster layer (memory), so the next request skips the expensive query entirely.
What caching actually solves
Cache isn't a cure-all. It only helps when:
- Data changes infrequently, or can tolerate a few seconds to a few minutes of staleness
- The same data is read many times by many users or many requests
- Computation cost is high: complex queries, large joins, heavy aggregates
- External APIs with rate limits or high latency
Cache doesn't help when data must be real-time: account balances, in-progress order status, per-transaction inventory.
Cache-Aside (Lazy Loading)
The most common strategy. The application manages the cache directly:
1. Read from cache
2. If found → return immediately (cache hit)
3. If not found → query database → write to cache → return
async function getUserById(id: string) {
const cacheKey = `user:${id}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.users.findUnique({ where: { id } });
if (user) {
await redis.set(cacheKey, JSON.stringify(user), 'EX', 300); // 5 min TTL
}
return user;
}
Advantages
- Only caches data that is actually read — no memory waste
- A cache miss doesn't crash the system, just slows down that request
- Easy to implement, no changes needed to the database layer
Disadvantages
- First cache miss is always slow — must hit the DB
- Thundering herd: when cache expires, many concurrent requests all miss at once and hammer the DB simultaneously
- Data in cache can go stale if the DB is updated outside the main code path
Handling the thundering herd
Use a lock when populating cache so only one request queries the DB:
async function getUserById(id: string) {
const cacheKey = `user:${id}`;
const lockKey = `lock:${cacheKey}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const lock = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (!lock) {
// Another request is populating — wait and retry
await sleep(50);
return getUserById(id);
}
try {
const user = await db.users.findUnique({ where: { id } });
if (user) await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);
return user;
} finally {
await redis.del(lockKey);
}
}
Write-Through
When writing to the database, simultaneously update the cache:
Write request → write DB → write cache → return OK
async function updateUser(id: string, data: UpdateUserDto) {
const user = await db.users.update({ where: { id }, data });
const cacheKey = `user:${id}`;
await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);
return user;
}
Advantages
- Cache always has fresh data after every write — no stale data
- Reads immediately after a write always hit the cache
Disadvantages
- Write latency increases since both places must be written
- Cache holds data for every record, even rarely-read ones — memory overhead
- If DB write succeeds but cache write fails, you have an inconsistency to handle
When to use
Good fit for data that is both read-heavy and frequently updated, where you need the cache to never go stale. Examples: user profiles, system settings, frequently-changing config.
Write-Behind (Write-Back)
Write to cache first, then flush to the database asynchronously:
Write request → write cache → return OK immediately
→ background job writes DB
Advantages
- Extremely low write latency — only costs the time to write to cache
- Suitable for high write-throughput systems: view counts, like counts, analytics events
Disadvantages
- Risk of data loss: if the cache server dies before flushing, that data is gone
- More complex to implement correctly
- Harder to debug when DB and cache are out of sync
Example: post view counter
async function incrementPostView(postId: string) {
await redis.incr(`post:${postId}:views`);
}
// Background job runs every 5 minutes — flushes to DB
async function flushViewCountsToDb() {
const keys = await redis.keys('post:*:views');
for (const key of keys) {
const postId = key.split(':')[1];
const views = parseInt(await redis.get(key) ?? '0');
await db.posts.update({
where: { id: postId },
data: { views: { increment: views } },
});
await redis.del(key);
}
}
Read-Through
The cache automatically fetches from the DB on a miss, rather than leaving that to the application. Usually implemented via a library or caching layer (like Cacheable in NestJS, or cache providers in some ORMs).
@Injectable()
export class UserService {
@Cacheable('user', { ttl: 300 })
async getUserById(id: string) {
return this.db.users.findUnique({ where: { id } });
}
}
Logically identical to Cache-Aside, but the application code doesn't need to manually handle cache hit/miss logic — the library handles it.
Cache Invalidation — the hardest part
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Three ways to invalidate cache:
1. TTL-based (auto-expire)
Simplest. Data automatically expires after a fixed time.
await redis.set(key, value, 'EX', 300); // expires in 5 minutes
Good when: data can be stale for a few minutes without serious impact (product catalog, articles, infrequently-changed config).
Not good when: data needs immediate consistency.
2. Event-based invalidation
Delete or update relevant cache keys when a write happens:
async function updateProductPrice(productId: string, price: number) {
await db.products.update({ where: { id: productId }, data: { price } });
// Delete all affected cache keys
await redis.del(`product:${productId}`);
await redis.del(`category:${product.categoryId}:products`); // list cache is also stale
await redis.del(`homepage:featured`);
}
The hard part: you have to know every cache key affected by a given write. As the system grows, this list gets long and it's easy to miss one.
3. Tag-based invalidation
Attach tags to cache keys, invalidate by tag:
await cacheWithTag(`product:${id}`, data, ['products', `category:${categoryId}`]);
// When a category changes, invalidate all keys with that tag
await invalidateByTag(`category:${categoryId}`);
More complex to implement yourself, but some cache libraries support it natively (Next.js has revalidateTag, some Redis wrappers support tags).
Choosing TTL values
No single number works for everything. Rough guidelines:
| Data type | Suggested TTL | Reason |
|---|---|---|
| Static config, feature flags | 5–15 min | Rarely changes, short staleness OK |
| Product catalog, articles | 1–5 min | Infrequent updates, expensive queries |
| Search results, lists | 30–60 sec | Short staleness OK, heavy queries |
| User profiles | 5 min + invalidate on write | Rare changes but must be correct when changed |
| Sessions, auth tokens | Match session lifetime | Must align with auth logic |
| Real-time data (prices, inventory) | No cache or TTL ≤5s | Staleness affects business logic |
Common mistakes
Caching entire API responses
Caching responses that include user-specific data causes user A to see user B's data. Only cache data that isn't user-specific, or include the user ID in the cache key.
Cache key isn't unique enough
// Wrong: two different queries share the same key
await redis.set('products', JSON.stringify(products));
// Right: key must reflect all relevant parameters
const key = `products:category:${categoryId}:page:${page}:limit:${limit}`;
Not handling cache stampede on restart
When deploying or restarting the cache server, all keys expire at once and all traffic hits the DB simultaneously. Solutions: use a stale-while-revalidate pattern, or warm the cache before traffic arrives.
Storing oversized objects
Redis works well for small to medium objects. Multi-MB objects belong in object storage — cache should only hold metadata or signed URLs.
Not monitoring cache hit rate
A hit rate below 80% usually signals a TTL that's too short, wrong cache keys, or data that simply isn't a good candidate for caching.
Checklist
- Is this data actually read many times with the same parameters?
- How long can this data be stale?
- Is the cache key unique enough to avoid serving wrong data?
- When writing, have you invalidated all affected cache keys?
- Is there a metric tracking hit rate?
- Have you considered thundering herd when cache mass-expires?
- Is the object being stored too large for cache?