⚡ API Response Caching Strategies
The fastest work is the work you never repeat. Caching stores a response so identical future requests are served in milliseconds instead of hitting your database again. This lesson covers what to cache, how HTTP already helps you, the main strategies, and the genuinely hard part: knowing when a cached answer has gone stale.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Define cache hit, miss, TTL, and hit ratio and explain why caching pays off
- Use HTTP
Cache-Controldirectives andETagvalidation correctly - Identify where caches live — client, CDN, gateway, application, database
- Apply time-based, validation-based, and variation-based caching to the right situations
- Choose an invalidation technique and avoid stale-data and cache-stampede pitfalls
Estimated Time: 45–60 minutes • Difficulty: Advanced
Hands-on: Add a Redis cache-aside layer to a slow endpoint and measure the gain.
In This Lesson
Why Cache at All?
Every uncached request repeats the same expensive work: query the database, run the computation, serialize the result. If a thousand users ask for the same product page in a minute, an uncached API does that work a thousand times. Caching does it once and hands out copies.
💡 A useful analogy: A busy coffee shop doesn't brew a fresh pot for every customer — it brews a batch and pours from it until it runs low. Caching is that batch: prepare the answer once, serve it quickly to everyone who asks, and only "brew again" when it goes stale.
The payoff is dramatic and compounding:
- Latency drops from hundreds of milliseconds to single digits.
- Server and database load fall, often by an order of magnitude.
- Throughput and resilience rise — you serve traffic spikes on the same hardware, and can keep serving cached data even if the backend hiccups.
- Cost shrinks because you provision for the cache-miss rate, not the total request rate.
⚠️ Caching's price
The tradeoff is freshness. A cache serves a snapshot, so for a window of time it may return data that no longer matches the source. The entire craft of caching is deciding how much staleness is acceptable, and reclaiming freshness when it isn't.
Caching Fundamentals
A handful of terms recur throughout every caching system:
📖 Key Terms
Cache hit: the requested item was found in the cache and served from it.
Cache miss: it wasn't there, so it had to be generated from the source and (usually) stored.
Cache key: the unique identifier under which a response is stored and looked up.
TTL (time-to-live): how long an entry stays valid before it expires.
Eviction: removing entries to reclaim memory (e.g. LRU — least recently used).
The headline metric is the cache hit ratio — the fraction of requests served from cache:
Cache hit ratio
hit ratio = cache hits / total requests
A 95% hit ratio means only one request in twenty reaches your backend. Small improvements here have outsized effects: going from 80% to 95% cuts backend load by three quarters. When a hit ratio is disappointing, the usual culprits are keys that are too specific (so nothing is reused) or TTLs that are too short (so entries expire before they're reused).
HTTP Caching & ETags
Before you build any custom cache, remember that HTTP has caching built in. By sending the right response headers, you let browsers and CDNs cache for you — for free.
Cache-Control
The Cache-Control header is the primary dial. Its directives compose:
| Directive | Meaning |
|---|---|
max-age=3600 | Fresh for 3600 seconds in any cache |
s-maxage=7200 | Like max-age, but only for shared caches (CDNs) |
public / private | May any cache store it, or only the end user's browser? |
no-cache | May store, but must revalidate before reuse |
no-store | Never store this response anywhere |
must-revalidate | Once stale, must check with the origin before serving |
// Public, cacheable for a day, revalidate once stale
res.set('Cache-Control', 'public, max-age=86400, must-revalidate');
// Per-user data that must never sit in a shared cache
res.set('Cache-Control', 'private, no-store');
ETags and conditional requests
An ETag is a fingerprint of a response. The client stores it and sends it back in an If-None-Match header; if the resource is unchanged, the server replies 304 Not Modified with an empty body — saving bandwidth even when the client still had to check in.
import crypto from 'node:crypto';
app.get('/api/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
const etag = crypto.createHash('md5')
.update(JSON.stringify(product))
.digest('hex');
// Client already has this exact version?
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.set('Cache-Control', 'public, max-age=3600');
res.json(product);
});
💡 Freshness vs. validation
max-age gives freshness: within the window, no server round-trip happens at all. ETag gives validation: a round-trip happens, but a 304 avoids re-sending the payload. The best APIs use both — a short max-age for zero-trip speed, and an ETag so revalidation after that is cheap.
Where Caches Live
A request can be answered by a cache at several points along its journey. The earlier a cache intercepts it, the faster and cheaper the response — but the less control you retain.
- Client cache — eliminates the network entirely, but you can't purge it after deployment.
- CDN / proxy — serves millions from the edge, close to users; great for public, non-personalized data.
- API gateway — centralized, can cache authenticated responses with the right keys.
- Application cache (Redis, Memcached) — the most flexible; you control exactly what and when.
- Database cache — query/result caches inside the DB itself.
Core Caching Strategies
Which strategy fits depends on how your data changes.
Time-based (TTL)
The simplest: store the response with an expiry and serve it until the TTL runs out. Perfect for data that's expensive to compute and tolerant of being a little stale — a product catalog, a weather summary, a top-10 list.
Validation-based
Use ETags or Last-Modified so the client keeps its copy until the server confirms a change. Best when accuracy matters and data changes irregularly, and you want to save bandwidth on the (frequent) unchanged case.
Variation-based
Different inputs produce different responses, so the cache key must encode every input that affects the output — query parameters, language, region. Get this wrong and you either miss constantly (keys too fine) or serve the wrong content (keys too coarse).
// The key must capture EVERY input that changes the response
const { page = 1, limit = 10, sort = 'createdAt', category = 'all' } = req.query;
const cacheKey = `products:${category}:${sort}:${page}:${limit}`;
For HTTP-level variation, advertise it with the Vary header so shared caches key correctly:
res.set('Vary', 'Accept-Language');
res.set('Cache-Control', 'public, max-age=600');
⚠️ Never cache per-user data in a shared cache
If a response depends on who is asking (their profile, their cart), a public/shared cache can leak one user's data to another. Mark such responses private (or no-store), or key the application cache by user id. This is one of the most dangerous caching bugs.
The Cache-Aside Pattern
The workhorse of application-level caching is cache-aside (also called lazy loading): check the cache first; on a miss, load from the source and populate the cache for next time.
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getProduct(id) {
const key = `product:${id}`;
// 1. Look aside to the cache first
const cached = await redis.get(key);
if (cached) return JSON.parse(cached); // hit
// 2. Miss — go to the source of truth
const product = await db.products.findById(id);
// 3. Populate the cache for next time (1-hour TTL)
await redis.set(key, JSON.stringify(product), 'EX', 3600);
return product;
}
Cache-aside is popular because it's resilient (if the cache is down, requests just fall through to the database) and it only caches data that's actually requested. Its weaknesses are the first-request latency on every miss and the risk of a cache stampede.
💡 Cache stampede
When a popular key expires, hundreds of concurrent requests all miss at once and hammer the database together. Mitigate it with a short lock (only one request regenerates while others wait), by adding jitter to TTLs so keys don't expire in sync, or with the stale-while-revalidate pattern below.
Stale-while-revalidate serves the slightly-expired value immediately while refreshing it in the background — the user never waits for the regeneration:
// Serve stale within a grace window, refresh asynchronously
if (age > ttl && age < ttl + gracePeriod) {
refreshInBackground(key); // fire-and-forget
res.set('X-Cache', 'stale');
return res.json(staleValue); // instant response
}
Cache Invalidation
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Invalidation is deciding when a cached entry no longer reflects reality and removing it. There's no single right answer — you pick based on how tolerant your data is of staleness.
| Technique | How it works | Trade-off |
|---|---|---|
| TTL expiry | Entry auto-expires after a set time | Simple, but data is stale until expiry |
| Explicit (write-through) | Delete/update the key when the source changes | Accurate, but you must track every key |
| Versioned keys | Bump a version prefix to invalidate en masse | Easy global reset; can't target one item |
| Event / pub-sub | Broadcast invalidation across instances | Works when distributed; more infrastructure |
| Surrogate keys / tags | Purge all entries sharing a tag (CDN) | Precise relational purges; needs CDN support |
The most common combination is TTL as a safety net plus explicit invalidation on writes. When you update a record, delete its cache key so the next read repopulates it, and let the TTL catch anything you forget:
app.put('/api/products/:id', async (req, res) => {
const product = await db.products.update(req.params.id, req.body);
// Explicit invalidation: drop the stale entry immediately
await redis.del(`product:${req.params.id}`);
res.json(product);
});
✅ Match TTL to volatility
Set the TTL from how fast the data changes and how much staleness users can tolerate — seconds for a live scoreboard, minutes for a product list, hours for a country list. A single global TTL is almost always wrong.
Hands-on: Cache a Slow Endpoint
🏋️ Add cache-aside and measure the win
Objective: Wrap a deliberately slow endpoint with a Redis cache-aside layer, then confirm the second request is dramatically faster and correctly invalidated on write.
Starting point — an endpoint whose data source takes ~500 ms:
app.get('/api/report/:id', async (req, res) => {
const report = await buildExpensiveReport(req.params.id); // ~500ms
res.json(report);
});
Instructions:
- Add a cache-aside check keyed by report id with a 60-second TTL.
- Add an
X-Cacheheader set toHITorMISSand time both requests. - Add a
PUTroute that rebuilds the report and invalidates the cached entry. - Note the latency of the miss vs. the hit — that difference is your payoff.
💡 Hint
Reuse the getProduct cache-aside shape from Section 6: redis.get first, return on hit; otherwise build, redis.set with 'EX', 60, and return. For invalidation, call redis.del in the PUT handler.
✅ Sample solution
app.get('/api/report/:id', async (req, res) => {
const key = `report:${req.params.id}`;
const cached = await redis.get(key);
if (cached) {
res.set('X-Cache', 'HIT'); // ~2ms
return res.json(JSON.parse(cached));
}
const report = await buildExpensiveReport(req.params.id); // ~500ms
await redis.set(key, JSON.stringify(report), 'EX', 60);
res.set('X-Cache', 'MISS');
res.json(report);
});
app.put('/api/report/:id', async (req, res) => {
const report = await rebuildReport(req.params.id, req.body);
await redis.del(`report:${req.params.id}`); // invalidate
res.json(report);
});
The first call is a MISS at ~500 ms; every call within the next 60 seconds is a HIT at a few milliseconds — a ~100x speedup and one database hit instead of many. The PUT ensures a stale report is never served after an update.
🎯 Quick Quiz
Question 1: What does a 304 Not Modified response indicate?
Question 2: In the cache-aside pattern, what happens on a cache miss?
Question 3: Why must a response that depends on the logged-in user be marked private (or not cached in a shared cache)?
Best Practices
✅ Do
- Lean on HTTP caching (
Cache-Control,ETag) before building a custom layer. - Set TTLs from each resource's real change frequency, not one global number.
- Invalidate explicitly on writes, with TTL as a safety net.
- Include every response-affecting input in the cache key (and set
Vary). - Guard against stampedes with locks, TTL jitter, or stale-while-revalidate.
- Monitor the hit ratio and tune keys and TTLs from real data.
⚠️ Don't
- Don't cache per-user or sensitive data in a shared/public cache.
- Don't cache non-idempotent responses (POST/PUT/DELETE results).
- Don't let cache failures take down the request path — fall through to the source.
- Don't pick TTLs so long that users routinely see stale data.
Summary & Quiz
🎉 Key Takeaways
- Caching trades a little freshness for large gains in latency, load, and cost; the hit ratio measures success.
- HTTP gives you caching for free via
Cache-Control(freshness) andETag(validation / 304s). - Caches live at many layers — client, CDN, gateway, app, database — trading speed for control.
- Match the strategy to your data: time-based, validation-based, or variation-based.
- Cache-aside is the go-to application pattern; guard it against stampedes.
- Invalidation is hard — combine explicit purges on write with a TTL safety net, and never share per-user data.
📚 Further Reading
🚀 What's Next?
We've now hardened, throttled, and accelerated REST APIs. Next we step back to compare architectures entirely: GraphQL vs. REST — how a query language changes the shape of the requests you're caching and securing.
🎉 Blazing fast!
You can now cut latency and load dramatically — and know exactly when a cached answer can be trusted.