🗄️ Node.js Caching Strategies: Stop Hitting the Database Like It Owes You Money
🗄️ Node.js Caching Strategies: Stop Hitting the Database Like It Owes You Money
Your database is crying. Every time a user hits /api/products, you're firing off the same query, fetching the same 200 rows, doing the same joins — and for what? Because you forgot that computers are really good at remembering things.
Caching is the closest thing to magic in backend development. Done right, it's the difference between an API that responds in 2ms and one that responds in 2 seconds. Let's fix your database abuse problem, one cache layer at a time.
The Problem: Goldfish Memory
Here's a classic Express endpoint that has no memory of what it did five seconds ago:
app.get('/api/products', async (req, res) => {
// This fires a full DB query. Every. Single. Time.
const products = await db.query('SELECT * FROM products WHERE active = true');
res.json(products);
});
If 500 users hit this endpoint simultaneously, you've just sent 500 identical queries to your database. Your DB is sweating. Your response times are climbing. Your users are refreshing the page, which makes it worse. It's a doom spiral, and it smells like burning.
Layer 1: In-Memory Caching (The Quick Fix)
The simplest cache is just a JavaScript object with an expiry attached. Think of it like a sticky note on your monitor — you check the note before running to the filing cabinet.
const cache = new Map();
function withCache(key, ttlMs, fetchFn) {
return async (...args) => {
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.data;
}
const data = await fetchFn(...args);
cache.set(key, { data, expiresAt: Date.now() + ttlMs });
return data;
};
}
// Wrap your DB call
const getCachedProducts = withCache('products:active', 60_000, () =>
db.query('SELECT * FROM products WHERE active = true')
);
app.get('/api/products', async (req, res) => {
const products = await getCachedProducts();
res.json(products);
});
Now the first request hits the DB, but every request for the next 60 seconds gets the cached result in microseconds. Your database just got a coffee break.
The catch: this cache lives in process memory. Restart your server? Cache gone. Run two instances of your app? Each has its own cache, inconsistency guaranteed. It's fine for single-server setups or non-critical data — but for anything serious, you need Layer 2.
Layer 2: Redis (The Real Deal)
Redis is an in-memory data store that lives outside your app. Think of in-memory cache as a whiteboard in your office versus Redis being a whiteboard in the hallway that everyone on the team can read and write.
Install the client:
npm install ioredis
Then build a proper cache middleware for Express:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
function cacheMiddleware(ttlSeconds) {
return async (req, res, next) => {
const key = `cache:${req.originalUrl}`;
const cached = await redis.get(key);
if (cached) {
res.setHeader('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
}
// Hijack res.json to intercept the response
const originalJson = res.json.bind(res);
res.json = async (data) => {
await redis.setex(key, ttlSeconds, JSON.stringify(data));
res.setHeader('X-Cache', 'MISS');
return originalJson(data);
};
next();
};
}
// Apply to any route
app.get('/api/products', cacheMiddleware(60), async (req, res) => {
const products = await db.query('SELECT * FROM products WHERE active = true');
res.json(products);
});
The X-Cache header is a nice touch — it lets you (and your future self debugging at 2am) instantly see whether the response came from cache or the DB. First request: MISS. Every request after: HIT. Beautiful.
Cache Invalidation: The Hard Part
There's a famous quote in computer science: "There are only two hard things: cache invalidation and naming things." Whoever said that was right and also being modest — cache invalidation is genuinely tricky.
The naive approach is TTL-based: let entries expire after N seconds and live with slightly stale data. This works great for product listings, blog posts, or anything that doesn't need to be millisecond-fresh.
For data that does need to be fresh after a write, you invalidate explicitly:
app.post('/api/products', async (req, res) => {
const newProduct = await db.insert('products', req.body);
// Bust the cache so the next GET fetches fresh data
await redis.del('cache:/api/products');
res.status(201).json(newProduct);
});
Write-through invalidation: whenever the source of truth changes, you nuke the cache entry. The next read takes the slow path and repopulates it. Simple, effective, and not nearly as scary as people make it sound.
Picking Your Strategy
| Scenario | Strategy |
|---|---|
| Single server, low traffic | In-memory Map with TTL |
| Multiple servers / high traffic | Redis with TTL |
| Data changes frequently | Short TTL + explicit invalidation |
| Data rarely changes | Long TTL (hours/days) |
| User-specific data | Cache key includes user ID |
The golden rule: cache at the layer closest to the user, invalidate at the layer closest to the data. Start simple, measure the impact, and add complexity only when the metrics demand it.
Don't Cache Everything
A few things that should never be cached without careful thought:
- Auth endpoints — stale session data is a security nightmare
- Payment flows — wrong price, lawsuit incoming
- User-specific sensitive data — unless your cache key is scoped tightly to the user
- Anything that changes per-request (random content, personalized feeds without keying)
Caching the wrong thing at the wrong layer can serve User A's data to User B. That's a bad day for everyone.
The Payoff
After implementing Redis caching on a product listing endpoint in a real project, I watched average response time drop from ~340ms to ~8ms. The database CPU utilization dropped by 60%. The bill went down. The users stopped complaining. Everyone was happy.
Your database is a precious resource. Treat it like one. Cache the reads that don't need to be fresh, invalidate when data changes, and let Redis do the heavy lifting between your app and your DB.
Your database will thank you. Probably.
What's your caching setup look like? Drop a comment below — I'm curious whether people are still rolling hand-rolled in-memory caches or if everyone's on Redis now. And if you found this useful, share it with that one teammate who's still querying the DB on every keystroke in a search box. They need this more than you do.