Back to Blog
Mar 18, 20264 min readOnuzulike Anthony

Multi-Layer Caching: CDN to Database and Back

How caching works at every layer of the stack, cache invalidation strategies, and why Cloudflare KV works well for per-user recommendation caches.

ArchitectureArchitecturePerformanceCaching

Caching is the most effective performance tool available, and also the source of some of the hardest bugs. The challenge isn't adding a cache — it's knowing which layer to cache at, when to invalidate it, and what the consistency requirements are.

The Cache Hierarchy

For a typical Next.js app deployed on Cloudflare, there are five places data can be cached:

Browser cache → Cloudflare CDN → Edge KV → In-process memory → Database query cache

Each layer is faster and more available than the one below it. Each layer is also less fresh.

Browser Cache

The furthest from your server, fastest for the end user. Controlled by Cache-Control headers. Good for: static assets, public data that doesn't change per-user (genre lists, site configuration).

http
Cache-Control: public, max-age=3600, stale-while-revalidate=86400

stale-while-revalidate is underused: it lets the browser serve stale content immediately while revalidating in the background. The user sees fast responses; the cache stays fresh.

Cloudflare CDN

Sits between the browser and your origin server. Cloudflare caches responses based on Cache-Control headers and the URL. Next.js's ISR (Incremental Static Regeneration) integrates with Cloudflare's cache via OpenNext, using R2 for the cache store.

For content that's the same for every user (home page, public docs, genre pages), CDN caching means your origin server is hit rarely — even under high traffic.

For user-specific content, CDN caching doesn't help and can be harmful if misconfigured. Always set Cache-Control: private or Vary: Cookie, Authorization for authenticated responses.

Cloudflare KV (Edge Key-Value)

KV is a globally distributed key-value store. Writes are eventually consistent (can take up to 60 seconds to propagate globally); reads are fast from any edge node.

NodWatch uses KV for per-user recommendation caches:

ts
const cacheKey = `recommendations:${userId}`;
const cached = await env.RECOMMENDATIONS_KV.get(cacheKey, 'json');
if (cached) return cached;
 
const recommendations = await computeRecommendations(userId);
await env.RECOMMENDATIONS_KV.put(cacheKey, JSON.stringify(recommendations), {
  expirationTtl: 3600 // 1 hour
});
return recommendations;

Why KV works here: recommendation computation is expensive (multiple DB queries, TMDB API calls, scoring). A 1-hour cache TTL is acceptable — recommendations don't need to be real-time. KV's eventual consistency doesn't matter because each user's cache is independent.

Where KV doesn't work: counters, session state, anything requiring strong consistency or frequent writes.

In-Process Memory

A Map or LRU cache in the application process. Zero network overhead. Works well for:

  • Configuration fetched from the database at startup
  • Parsed schemas or compiled patterns
  • Small datasets that change infrequently

The risk: each process has its own cache. In a distributed deployment, process A's cache can be stale while process B's is fresh. For data where consistency matters, use a shared cache (Redis, KV) instead.

Database Query Cache

Most databases don't have a built-in query result cache (PostgreSQL doesn't, despite common belief). "Database caching" usually means:

  • Connection pooling (Hyperdrive, pg-pool) to reduce connection overhead
  • Indexed queries that return quickly
  • Materialized views for expensive aggregations

Neon + Hyperdrive (what NodWatch uses) handles connection pooling at the edge, keeping connections warm across Cloudflare Worker invocations that are otherwise stateless.

Cache Invalidation

Phil Karlton's "two hard things" quote is famous because invalidation is genuinely hard. The main strategies:

TTL-based expiry. Set a time-to-live and accept that data can be stale for up to that period. Simple, predictable. Good for: recommendations, aggregated stats, search results.

Event-driven invalidation. When data changes, explicitly delete the cache entry. More complex but keeps the cache consistent. Good for: user profile data, per-resource caches.

ts
// When a user updates their watchlist, invalidate their recommendations cache
async function addToWatchlist(userId: string, item: WatchlistItem) {
  await db.insert(watchlist).values({ userId, ...item });
  await env.RECOMMENDATIONS_KV.delete(`recommendations:${userId}`);
}

Cache-aside with version keys. Include a version or hash in the cache key. When the underlying data changes, increment the version. Old keys naturally expire.

The most common caching mistake is not having a plan for invalidation before adding a cache. If you can't articulate what changes would make the cached data stale and how you'd handle it, the cache will eventually serve wrong data.