March 26, 20268 min read

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.

caching redis cdn performance web
Ad 336x280

"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

LayerWhereWhat it cachesTTL range
Browser cacheUser's deviceStatic assets, API responsesMinutes to months
CDN cacheEdge servers worldwideStatic files, full page HTML, API responsesSeconds to days
Application cacheYour server (Redis/Memcached)Database queries, computed values, session dataSeconds to hours
These layers stack. A request might be served from browser cache (instant), then CDN cache (fast, no server load), then application cache (fast, no database query), and only hit your database if all three miss.

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
DirectiveMeaning
publicAny cache (browser, CDN, proxy) can store this
privateOnly the browser can cache this (not CDN) — use for user-specific data
max-age=NCache for N seconds
no-cacheCache it, but revalidate with the server every time
no-storeDon't cache at all — sensitive data like banking pages
immutableThe resource will never change at this URL (use with hashed filenames)
stale-while-revalidate=NServe 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 typeCache?Strategy
Static assets (JS, CSS, images)AlwaysLong TTL, hashed filenames
Public pages (marketing, blog)Usually1-60 minute TTL
API responses (public data)SometimesShort TTL with stale-while-revalidate
User-specific pages/APIsNever at CDNUse Cache-Control: private
Authentication endpointsNeverno-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

ScenarioCaching strategy
Static assets (CSS, JS, images)Browser + CDN, long TTL, filename hashing
Public API responsesCDN + Redis, short TTL + stale-while-revalidate
Database query resultsRedis, TTL based on staleness tolerance
Session dataRedis, TTL matching session duration
User-specific dashboardRedis, invalidate on relevant writes
Real-time data (stock prices, chat)Don't cache — use WebSockets
Authentication tokensno-store — never cache
Caching is about trade-offs: speed versus freshness, complexity versus simplicity. Start with the simplest strategy that gives acceptable performance and add complexity only when you have evidence that you need it. For more backend optimization patterns, explore the exercises on CodeUp.
Ad 728x90