Caching Strategies
Speed up your API with caching - Redis patterns, HTTP caching, and cache invalidation.
Why Cache?#
Every database query takes time - network round trips, disk reads, query execution. For data that doesn't change often, why fetch it repeatedly?
Without cache:
User → API → Database → API → User
↑ ↑
~10ms ~80ms Total: ~100ms
With cache:
User → API → Cache → API → User
↑ ↑
~10ms ~2ms Total: ~15ms
What caching does:
- Reduces latency (faster responses)
- Reduces database load (fewer queries)
- Reduces costs (less compute, fewer connections)
- Improves reliability (cache can serve if database is slow)
When caching makes sense:
- Data is expensive to compute or fetch
- Data is read more often than written
- Slightly stale data is acceptable
- Same data is requested repeatedly
Caching Layers#
Your application has multiple opportunities to cache data. Each layer has different characteristics:
┌─────────────────────────────────────────────────────────────────┐
│ Client Side │
│ Browser Cache │ Service Worker │ CDN │
│ (instant) (offline capable) (edge locations) │
├─────────────────────────────────────────────────────────────────┤
│ Application Server │
│ In-Memory Cache │ Redis/Memcached │ Response Cache │
│ (fastest) (shared, persistent) (computed results) │
├─────────────────────────────────────────────────────────────────┤
│ Database │
│ Query Cache │ Index Cache │
│ (automatic) (built-in) │
└─────────────────────────────────────────────────────────────────┘
Client-side caching: Browser stores responses. Free, instant, but you can't control it directly.
Application caching (Redis): Shared across all your server instances. Survives restarts. Most flexible.
In-memory caching: Fastest possible, but local to one server instance. Lost on restart.
Database caching: Built-in query caches. Automatic but limited control.
Redis Caching#
Redis is the standard choice for application-level caching. It's fast (in-memory), shared (all servers see the same data), and persistent (survives restarts).
The Cache-Aside Pattern#
The most common pattern. Your application manages the cache explicitly:
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
async function getUser(id) {
const cacheKey = `user:${id}`;
// 1. Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached); // Cache hit - return immediately
}
// 2. Cache miss - fetch from database
const user = await User.findById(id);
if (!user) return null;
// 3. Store in cache for next time
await redis.setEx(cacheKey, 3600, JSON.stringify(user)); // 1 hour TTL
return user;
}
How it works:
- Application checks if data exists in cache
- If yes (cache hit), return it immediately
- If no (cache miss), fetch from database
- Store the result in cache for future requests
- Return the result
Why "cache-aside"? The cache sits beside the database, not between. Your code decides when to read/write each.
A Reusable Cache Service#
Wrap Redis operations in a service for cleaner code:
// src/services/cache.js
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
export const cache = {
async get(key) {
const value = await redis.get(key);
return value ? JSON.parse(value) : null;
},
async set(key, value, ttlSeconds = 3600) {
await redis.setEx(key, ttlSeconds, JSON.stringify(value));
},
async del(key) {
await redis.del(key);
},
async delPattern(pattern) {
// Delete all keys matching a pattern
// Useful for invalidating related data
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(keys);
}
},
// The most useful helper - get from cache or fetch and cache
async getOrSet(key, fetchFn, ttlSeconds = 3600) {
const cached = await this.get(key);
if (cached) return cached;
const value = await fetchFn();
if (value) {
await this.set(key, value, ttlSeconds);
}
return value;
},
};
Using the Cache Service#
// src/services/userService.js
import { cache } from './cache.js';
export const UserService = {
async findById(id) {
// getOrSet handles the cache-aside logic
return cache.getOrSet(
`user:${id}`,
() => User.findById(id),
3600 // 1 hour
);
},
async findAll({ page, limit }) {
return cache.getOrSet(
`users:page:${page}:limit:${limit}`,
async () => {
return User.find()
.skip((page - 1) * limit)
.limit(limit);
},
300 // 5 minutes - lists change more often
);
},
async update(id, data) {
const user = await User.findByIdAndUpdate(id, data, { new: true });
// CRITICAL: Invalidate cache when data changes
await cache.del(`user:${id}`);
await cache.delPattern('users:page:*'); // Clear all list caches
return user;
},
};
Key principle: When data changes, invalidate the cache. Otherwise, users see stale data.
Caching Patterns Explained#
Different patterns suit different situations. Understanding when to use each is more important than knowing the code.
1. Cache-Aside (Lazy Loading)#
What: Application manages cache explicitly. Load data into cache on first request.
When to use:
- General-purpose caching
- When you need fine-grained control
- When not all data needs to be cached
Trade-offs:
- First request is always slow (cache miss)
- Cache and database can get out of sync
async function getData(key) {
let data = await cache.get(key);
if (!data) {
data = await database.get(key);
await cache.set(key, data);
}
return data;
}
2. Write-Through#
What: Write to cache and database together, synchronously.
When to use:
- When consistency is critical
- When you can afford slightly slower writes
- When reads are much more frequent than writes
Trade-offs:
- Writes are slower (two operations)
- Cache always has fresh data
async function saveData(key, value) {
await database.save(key, value); // Write to database
await cache.set(key, value); // Also write to cache
// Both happen before returning
}
3. Write-Behind (Write-Back)#
What: Write to cache immediately, persist to database asynchronously.
When to use:
- High-volume writes where some data loss is acceptable
- When write performance is critical
- Analytics, metrics, logs
Trade-offs:
- Risk of data loss if cache fails before database sync
- Fastest writes possible
- Complex to implement correctly
async function saveData(key, value) {
await cache.set(key, value); // Immediate - fast
await queue.add('sync-to-db', { key, value }); // Background job
}
4. Read-Through#
What: Cache automatically fetches from database on miss.
When to use:
- When you want simpler application code
- When using a caching library that supports it
Trade-offs:
- Less control over caching logic
- Cleaner application code
// Some caching libraries handle this automatically
const data = await cache.get(key); // Auto-fetches if not cached
Which Pattern to Choose?#
High read volume, occasional writes → Cache-Aside
Need strong consistency → Write-Through
High write volume, can lose data → Write-Behind
Want simpler code → Read-Through
Most applications use cache-aside because it's simple and gives you full control.
HTTP Caching#
Let browsers and CDNs cache responses. This is free performance - the request never hits your server.
Cache-Control Header#
The Cache-Control header tells browsers and CDNs how to cache responses:
// Public data - CDNs can cache it
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600'); // 1 hour
res.json({ data: products });
});
// Private data - only the user's browser can cache
app.get('/api/me', authenticate, (req, res) => {
res.set('Cache-Control', 'private, max-age=60'); // 1 minute
res.json({ data: req.user });
});
// Never cache - real-time or sensitive data
app.get('/api/orders', (req, res) => {
res.set('Cache-Control', 'no-store');
res.json({ data: orders });
});
// Stale-while-revalidate - serve stale, fetch fresh in background
app.get('/api/posts', (req, res) => {
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.json({ data: posts });
});
Key directives:
public- CDNs can cache (for everyone)private- Only browser can cache (user-specific data)max-age- How long to cache (seconds)no-store- Never cachestale-while-revalidate- Serve stale, revalidate in background
ETags for Conditional Requests#
ETags let clients ask "has this changed?" without re-downloading unchanged data:
import crypto from 'crypto';
app.get('/api/products/:id', async (req, res) => {
const product = await Product.findById(req.params.id);
// Generate ETag from content
const etag = crypto
.createHash('md5')
.update(JSON.stringify(product))
.digest('hex');
// If client has the current version, just say "not modified"
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Nothing to send
}
res.set('ETag', etag);
res.set('Cache-Control', 'private, max-age=0, must-revalidate');
res.json({ data: product });
});
How it works:
- First request: Server sends data + ETag
- Next request: Client sends ETag in
If-None-Matchheader - Server compares: Same? Return 304 (no body). Different? Return new data.
The Hard Problem: Cache Invalidation#
"There are only two hard things in Computer Science: cache invalidation and naming things." - Phil Karlton
When data changes, the cache becomes stale. You have three options:
1. Time-Based Expiration (TTL)#
Let data expire naturally after a set time.
await cache.set('key', value, 3600); // Expires in 1 hour
Pros: Simple, automatic Cons: Data can be stale until TTL expires
Best for: Data where slight staleness is acceptable (product listings, blog posts).
2. Event-Based Invalidation#
Explicitly delete cache when data changes.
async function updateUser(id, data) {
await User.findByIdAndUpdate(id, data);
await cache.del(`user:${id}`); // Immediately invalidate
}
Pros: Always fresh data Cons: Must remember to invalidate everywhere data changes
Best for: Data that must be accurate (user profiles, account balances).
3. Tag-Based Invalidation#
Group related cache entries and invalidate them together.
async function cacheWithTags(key, value, tags, ttl) {
await cache.set(key, value, ttl);
for (const tag of tags) {
await redis.sAdd(`tag:${tag}`, key); // Track which keys have this tag
}
}
async function invalidateTag(tag) {
const keys = await redis.sMembers(`tag:${tag}`);
if (keys.length > 0) {
await redis.del(keys); // Delete all tagged keys
await redis.del(`tag:${tag}`); // Clean up the tag set
}
}
// Usage
await cacheWithTags('user:123', user, ['users', 'team:456'], 3600);
await cacheWithTags('user:124', user, ['users', 'team:456'], 3600);
// Later: invalidate all users in a team
await invalidateTag('team:456'); // Both users invalidated
Pros: Invalidate groups of related data easily Cons: More complex to implement and maintain
Best for: Related data that changes together (team members, category products).
In-Memory Caching#
For extremely hot data that rarely changes, use in-memory caching. It's even faster than Redis because there's no network round trip.
import NodeCache from 'node-cache';
const memoryCache = new NodeCache({
stdTTL: 600, // 10 minutes default
checkperiod: 120, // Check for expired every 2 minutes
});
async function getConfig() {
let config = memoryCache.get('app-config');
if (!config) {
config = await Config.findOne({ active: true });
memoryCache.set('app-config', config);
}
return config;
}
Warning: In-memory cache is local to each server. If you have multiple servers, they'll have different caches. Use for:
- Configuration that rarely changes
- Computed values that are expensive but identical across requests
- Rate limiting counters (per server is usually fine)
Choosing the Right Cache#
| Cache Type | Speed | Persistent | Shared | Best For |
|---|---|---|---|---|
| In-memory | Fastest | No | No | Config, hot computed data |
| Redis | Very fast | Yes | Yes | Sessions, API responses, general |
| CDN | Instant* | Yes | Yes | Static assets, public API responses |
| Browser | Instant | Yes | No | User-specific, previously fetched |
*CDN is instant for the user because it's geographically close.
Key Takeaways#
-
Cache expensive operations - Database queries, API calls to external services, complex computations.
-
Set appropriate TTLs - Balance freshness vs performance. User profiles might cache for hours; stock prices might cache for seconds.
-
Invalidate on writes - When data changes, clear the cache. Forgetting this leads to confused users seeing old data.
-
Use HTTP caching - Let browsers and CDNs do the work. It's free and reduces server load.
-
Monitor hit rates - A cache with a 50% hit rate is doing less than half its job. Track and optimize.
-
Start simple - Cache-aside with TTL handles most cases. Add complexity only when needed.
Remember: Caching adds complexity. Only cache what's actually slow or frequently accessed. Premature optimization through caching can make debugging harder and introduce subtle bugs.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.