Caching Strategies for Web Developers — Redis, CDN, and Browser Cache
Practical caching guide covering Redis, CDN caching, browser cache headers, cache invalidation patterns, and when each strategy makes sense.
"There are only two hard things in computer science: cache invalidation and naming things." That quote is from 1996 and it's still true. Caching is easy to add and hard to get right. You'll speed up your app 10x and then spend days debugging why users see stale data.
This guide covers the three caching layers every web developer should understand, when to use each, and how to invalidate without losing your mind.
The Three Caching Layers
| Layer | Where | What it caches | TTL range |
|---|---|---|---|
| Browser cache | User's device | Static assets, API responses | Minutes to months |
| CDN cache | Edge servers worldwide | Static files, full page HTML, API responses | Seconds to days |
| Application cache | Your server (Redis/Memcached) | Database queries, computed values, session data | Seconds to hours |
Layer 1: Browser Cache (HTTP Cache Headers)
The browser cache is free performance. You just need the right HTTP headers.
Cache-Control
The most important header. It tells the browser (and intermediate caches) how to cache the response:
Cache-Control: public, max-age=31536000, immutable
| Directive | Meaning |
|---|---|
public | Any cache (browser, CDN, proxy) can store this |
private | Only the browser can cache this (not CDN) — use for user-specific data |
max-age=N | Cache for N seconds |
no-cache | Cache it, but revalidate with the server every time |
no-store | Don't cache at all — sensitive data like banking pages |
immutable | The resource will never change at this URL (use with hashed filenames) |
stale-while-revalidate=N | Serve stale cache while fetching fresh version in background |
The Caching Strategy for Different File Types
// Express middleware example
app.use("/static", express.static("public", {
maxAge: "1y", // Static assets with hashed filenames
immutable: true,
}));
// API responses — short cache with revalidation
app.get("/api/products", (req, res) => {
res.set("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
res.json(products);
});
// User-specific data — private, short TTL
app.get("/api/me", (req, res) => {
res.set("Cache-Control", "private, max-age=0, no-cache");
res.json(req.user);
});
// Never cache authentication responses
app.post("/api/login", (req, res) => {
res.set("Cache-Control", "no-store");
// ...
});
ETag and Conditional Requests
ETags let the browser check if its cached copy is still valid without downloading the full response:
const crypto = require("crypto");
app.get("/api/products/:id", async (req, res) => {
const product = await db.product.findUnique({ where: { id: req.params.id } });
const etag = crypto
.createHash("md5")
.update(JSON.stringify(product))
.digest("hex");
res.set("ETag", "${etag}");
res.set("Cache-Control", "private, no-cache");
// If the client has the same version, return 304 Not Modified
if (req.headers["if-none-match"] === "${etag}") {
return res.status(304).end();
}
res.json(product);
});
A 304 response has no body — the browser uses its cached copy. This saves bandwidth, especially for large responses.
Layer 2: CDN Caching
A CDN (Cloudflare, Fastly, CloudFront) caches your content on edge servers worldwide. A user in Tokyo gets served from a Tokyo edge server instead of your origin server in Virginia.
What to Cache at the CDN Level
| Content type | Cache? | Strategy |
|---|---|---|
| Static assets (JS, CSS, images) | Always | Long TTL, hashed filenames |
| Public pages (marketing, blog) | Usually | 1-60 minute TTL |
| API responses (public data) | Sometimes | Short TTL with stale-while-revalidate |
| User-specific pages/APIs | Never at CDN | Use Cache-Control: private |
| Authentication endpoints | Never | no-store |
Cloudflare Cache Rules Example
# Cache static assets for 1 year
URL: /static/*
Cache TTL: 31536000
Edge TTL: 31536000
# Cache blog pages for 1 hour, serve stale for 1 day
URL: /blog/*
Cache TTL: 3600
Stale-While-Revalidate: 86400
# Don't cache API by default
URL: /api/*
Cache TTL: 0 (bypass)
Cache Busting for Static Assets
When you deploy new CSS or JavaScript, browsers still have the old version cached. Solutions:
<!-- Filename hashing (best approach) -->
<link rel="stylesheet" href="/static/main.a1b2c3d4.css">
<script src="/static/app.e5f6g7h8.js"></script>
<!-- Query string (works but some CDNs ignore query strings) -->
<link rel="stylesheet" href="/static/main.css?v=1.2.3">
Build tools (Webpack, Vite, Next.js) handle filename hashing automatically. Each build produces new filenames for changed files. Old filenames still work from cache, new filenames force a fresh download.
Layer 3: Application Cache with Redis
Redis is the most common application-level cache. It sits between your application code and your database, storing the results of expensive queries and computations.
npm install ioredis
Basic Cache Pattern
const Redis = require("ioredis");
const redis = new Redis(process.env.REDIS_URL);
async function getCached(key, ttlSeconds, fetchFn) {
// Try cache first
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Cache miss — fetch from source
const data = await fetchFn();
// Store in cache
await redis.set(key, JSON.stringify(data), "EX", ttlSeconds);
return data;
}
// Usage
app.get("/api/products", async (req, res) => {
const products = await getCached("products:all", 300, async () => {
return db.product.findMany({
include: { category: true, reviews: true },
orderBy: { createdAt: "desc" },
});
});
res.json(products);
});
Cache Keys That Make Sense
Bad cache keys cause stale data bugs. Good cache keys are deterministic and specific:
// BAD: Too generic
const key = "products";
// BAD: Non-deterministic
const key = products:${Date.now()};
// GOOD: Specific and deterministic
const key = products:category:${categoryId}:page:${page}:sort:${sortBy};
// GOOD: User-specific
const key = user:${userId}:dashboard:stats;
// GOOD: With version prefix (for cache schema changes)
const key = v2:products:${productId};
Cache Invalidation Patterns
This is the hard part. When underlying data changes, the cache must be updated. There are several strategies:
Time-based expiration (simplest):// Cache for 5 minutes. After that, next request fetches fresh data.
await redis.set(key, data, "EX", 300);
Good enough for data that doesn't need to be immediately consistent. Blog posts, product listings, analytics dashboards.
Write-through (update cache on write):async function updateProduct(id, data) {
const product = await db.product.update({ where: { id }, data });
// Update the cache immediately
await redis.set(product:${id}, JSON.stringify(product), "EX", 3600);
// Also invalidate any list caches that include this product
await redis.del("products:all");
await redis.del(products:category:${product.categoryId}:*);
return product;
}
Event-driven invalidation:
// After any write operation
eventBus.emit("product:updated", { id: product.id });
// Listener clears relevant caches
eventBus.on("product:updated", async ({ id }) => {
const keys = await redis.keys(product${id}*);
if (keys.length > 0) {
await redis.del(...keys);
}
});
Avoid redis.keys() in production — it scans every key and blocks Redis. Use key tagging or maintain a set of related keys instead:
// Tag-based invalidation
async function cacheWithTags(key, data, ttl, tags) {
await redis.set(key, JSON.stringify(data), "EX", ttl);
for (const tag of tags) {
await redis.sadd(tag:${tag}, key);
}
}
async function invalidateTag(tag) {
const keys = await redis.smembers(tag:${tag});
if (keys.length > 0) {
await redis.del(...keys);
}
await redis.del(tag:${tag});
}
// Usage
await cacheWithTags(
product:${id},
product,
3600,
["products", category:${product.categoryId}]
);
// When a product is updated, invalidate all caches tagged "products"
await invalidateTag("products");
Caching Anti-Patterns
Cache stampede (thundering herd). Cache expires. 1,000 concurrent requests all miss cache and hit the database simultaneously. Fix: use a lock so only one request fetches the data while others wait:async function getCachedWithLock(key, ttlSeconds, fetchFn) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = lock:${key};
const locked = await redis.set(lockKey, "1", "EX", 10, "NX");
if (locked) {
try {
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), "EX", ttlSeconds);
return data;
} finally {
await redis.del(lockKey);
}
}
// Someone else is fetching — wait and retry
await new Promise((r) => setTimeout(r, 100));
return getCachedWithLock(key, ttlSeconds, fetchFn);
}
Caching nulls. A request for a non-existent product hits the database every time (cache miss). Cache the "not found" result too:
const NOT_FOUND = "__NOT_FOUND__";
async function getCachedOrNull(key, ttl, fetchFn) {
const cached = await redis.get(key);
if (cached === NOT_FOUND) return null;
if (cached) return JSON.parse(cached);
const data = await fetchFn();
if (data === null) {
await redis.set(key, NOT_FOUND, "EX", 60); // Short TTL for negatives
} else {
await redis.set(key, JSON.stringify(data), "EX", ttl);
}
return data;
}
Over-caching. Not everything needs caching. If a query takes 2ms and runs 10 times per minute, caching adds complexity for negligible benefit. Cache queries that are slow (>50ms), frequent (>100 per minute), or expensive (complex joins, external API calls).
When to Use What
| Scenario | Caching strategy |
|---|---|
| Static assets (CSS, JS, images) | Browser + CDN, long TTL, filename hashing |
| Public API responses | CDN + Redis, short TTL + stale-while-revalidate |
| Database query results | Redis, TTL based on staleness tolerance |
| Session data | Redis, TTL matching session duration |
| User-specific dashboard | Redis, invalidate on relevant writes |
| Real-time data (stock prices, chat) | Don't cache — use WebSockets |
| Authentication tokens | no-store — never cache |