Skip to content

Caching Strategies: Redis, CDN, and Multi-Level Caches

Core Concept LearningJuly 4, 20268 min readUpdated July 21, 2026

A product page is easy to cache until a price changes: the CDN still has the old response, one app instance has an older in-process value, and Redis has already been refreshed. The page is fast, but checkout rejects what the customer just saw. That failure is a cache-strategy problem, not a reason to avoid caching.

This guide focuses on patterns: cache-aside, read-through, write-through, CDN caching, invalidation, and coordination across cache levels. Use Caching 101 for layers and eviction, and Why Is Redis So Fast? for Redis internals.

A request falls through cache layers before hitting the database
A request falls through cache layers before hitting the database

Why Caching Matters

A cache helps when repeated requests would otherwise repeat the same database query, API call, or computation. A hit avoids that origin work; a miss still pays for it. The size of the benefit depends on the workload, network, serialization, key design, and whether the origin was actually the bottleneck, so benchmark the complete request path rather than quoting a universal latency win.

Start with one endpoint. If most requests share a result and brief staleness is acceptable, caching can reduce origin load. If results are per-user, change on every write, or represent money or permissions, first define the freshness and isolation rules; a fast stale answer can be worse than a slower correct one.

Quick reference

  • Cache-hit ratio is the share of lookups served from cache; set a target from your measured origin budget, not an industry percentage.
  • Cache miss: request falls through to the origin (database or API). Every cold start is a miss.
  • Hot data: the small subset of records that account for most requests — often follows a power-law distribution.
  • Cold data: rarely-accessed records that waste cache memory if kept. Eviction policies handle this.
  • Cache warming: pre-populating cache at startup to avoid a wave of misses under initial load.
  • Decision flow: identify the slow dependency → group requests by cache key → state the tolerated staleness → load-test misses → cache only if origin work falls without correctness regressions.

Remember this

A fast stale answer can be worse than a slower correct one — name the origin bottleneck, the cache key, and the tolerated staleness before picking a pattern, not after.

Cache-Aside (Lazy Loading)

In cache-aside, the application checks the cache first. On a miss, it fetches from the database, attempts to cache the result, then returns it. The cache is populated lazily, so only requested data enters it.

The pattern can degrade cleanly when cache reads and fills are treated as optional: a cache outage falls through to the database. Its cost appears on misses, which pay the origin work, and during simultaneous misses for one key, which can overload the origin unless requests are coalesced or locked.

Cache-aside: check cache, fall through to DB on a miss, then populate
Cache-aside: check cache, fall through to DB on a miss, then populate

Quick reference

  • On a cache miss, add jitter to TTL (e.g. 270–330s) to prevent all keys expiring simultaneously.
  • For thundering herd: use a mutex lock (Redis SETNX) so only one request populates the key.
  • Cache-aside works with any backend — database, external API, or computed result.
  • If the cache node is unavailable, fall through to the database — don't let cache failures crash the app.
Without cache — every request hits the database
1async function getProduct(id: string): Promise<Product | null> {2  try {3    return await db.products.findById(id);4  } catch (error) {5    logger.error({ error, id }, "product lookup failed");6    throw new ServiceUnavailableError("Product service unavailable");7  }8}
Cache-aside — database only on first miss
1async function getProduct(id: string): Promise<Product | null> {2  const cacheKey = `product:${id}`;3 4  try {5    const cached = await redis.get(cacheKey);6    if (cached) return JSON.parse(cached) as Product;7  } catch (error) {8    logger.warn({ error, cacheKey }, "cache read failed");9    // Redis is an optimization; continue to the source of truth.10  }11 12  let product: Product | null;13  try {14    product = await db.products.findById(id);15  } catch (error) {16    logger.error({ error, id }, "product lookup failed");17    throw new ServiceUnavailableError("Product service unavailable");18  }19  if (!product) return null;20 21  try {22    await redis.set(cacheKey, JSON.stringify(product), "EX", 300);23  } catch (error) {24    logger.warn({ error, cacheKey }, "cache fill failed");25  }26 27  return product;28}

Remember this

Redis is an optimization, not the source of truth — a cache-read failure should fall through to the database silently, while an origin failure still has to surface as a real error, not a cached miss.

Read-Through and Write-Through

Read-through differs from cache-aside in who owns miss handling. A cache abstraction or loader fetches from the source when a key is absent, so application callers use one interface. This centralizes policy, but failures still need a contract: return a typed not-found result, propagate an origin error, and avoid retrying a failed load from every caller.

With write-through, the write path updates the cache and source before acknowledging success. That narrows some stale-read windows but does not guarantee freshness by itself: concurrent writers, partial failures, replicas, and other cache layers can still disagree. Use it when the extra write-path coordination is justified, and define which store wins if only one write succeeds.

Quick reference

  • Read-through: useful when a framework, library, or service layer supports a loader with explicit not-found and error semantics.
  • Write-through avoids cache misses on data you just wrote — the next read finds it immediately.
  • Write-through adds another synchronous operation or coordination step to the write path; measure its effect under your workload.
  • Write-behind (async): write to cache immediately, flush to DB asynchronously — higher risk, faster writes.
  • Refresh-ahead: proactively reload cache keys before TTL expires to avoid latency spikes on expiry.

Remember this

Write-through narrows the stale-read window but doesn't guarantee freshness by itself — concurrent writers and partial failures can still leave cache and source disagreeing, so define which store wins before it happens in production.

Redis Data Structures for Caching

Redis isn't just a key-value store — its native data structures let you cache complex access patterns efficiently without deserializing entire objects.

Strings store serialized JSON for simple object caching. Hashes store individual fields of a record, so you can update one field without fetching and rewriting the full object. Sorted Sets let you maintain a leaderboard or a time-ordered feed in cache. Sets support fast membership checks — 'is user X in the beta group?' answered in O(1). Lists power queues and recent-activity feeds. Choosing the right structure directly reduces memory usage and eliminates unnecessary round trips.

Redis data structures match different access patterns
Redis data structures match different access patterns

Quick reference

  • String (GET/SET): best for full-object caching. Use JSON.stringify / JSON.parse.
  • Hash (HSET/HGET): best for records with many fields you update independently.
  • Sorted Set (ZADD/ZRANGE): leaderboards, time-series feeds, priority queues.
  • Set (SADD/SISMEMBER): membership checks — rate limiting, feature flags, dedup.
  • List (LPUSH/LRANGE): recent activity, job queues. Use Streams for durable queues.
  • Set a TTL on every key with EXPIRE or the EX option — unbounded keys fill memory and cause OOM eviction.
Naive — serialize full user on every partial update
1// Update just the display name — must fetch and rewrite entire object2const user = JSON.parse(await redis.get(`user:${id}`) ?? "{}");3user.displayName = newName;4await redis.set(`user:${id}`, JSON.stringify(user));
Hash — update only the changed field
1// Hash: store each field separately2await redis.hset(`user:${id}`, {3  displayName: newName,4});5 6async function getCachedUser(id: string): Promise<User | null> {7  try {8    const fields = await redis.hgetall(`user:${id}`);9    if (Object.keys(fields).length === 0) return null;10    return parseUser(fields); // Validate required fields and types.11  } catch (error) {12    logger.warn({ error, id }, "Redis hash read failed");13    return null; // Caller can load the source of truth.14  }15}

Remember this

Match the Redis data structure to the access pattern. Hashes save memory on partial updates; Sorted Sets make range queries trivial.

CDN Edge Caching

A CDN can answer a request at an edge location without sending it to the origin. Whether that improves latency depends on edge placement, connection reuse, object size, and cache status; verify it with CDN logs and regional tests rather than assuming a fixed edge-versus-origin gap.

For versioned static assets, long-lived caching is usually safe because a content hash changes the URL. API responses require stricter reasoning: cache only responses that are safe to share, include every representation-changing dimension in the cache key, and purge or revalidate when the source changes.

Quick reference

  • Example policy for content-hashed assets: Cache-Control: public, max-age=31536000, immutable. The URL must change when bytes change.
  • Example API policy: Cache-Control: public, s-maxage=60, stale-while-revalidate=600. Choose both windows from the endpoint's freshness contract.
  • Vary: Accept-Encoding — tells the CDN to cache separate copies for gzip vs br responses.
  • Cache-Control: private — prevents CDN from caching user-specific responses.
  • Purge by tag (Cloudflare Cache Tags, Fastly Surrogate-Key) to invalidate groups of keys on content change.
  • stale-while-revalidate: serve the old response while fetching a fresh one — eliminates latency on expiry.

Remember this

Cache-Control: private is what stops a CDN from caching a user-specific response as if it were public — miss one Vary dimension and the wrong customer's data gets served from the edge.

Cache Invalidation: The Hard Part

Invalidation is difficult because the cache and source of truth can change on different paths and fail independently. Every copy needs an owner, a freshness bound, and an observable response when deletion or refresh fails.

Three useful approaches are TTL-based expiry (simple, with a bounded stale window), event-driven invalidation (targeted but dependent on reliable delivery), and versioned keys (new writes use a new namespace while old keys age out). They can be combined; none removes the need to test races.

One price write: Redis updated, CDN still serves yesterday’s HTML
One price write: Redis updated, CDN still serves yesterday’s HTML

Quick reference

  • TTL expiry: simple but accepts eventual consistency. Choose TTL based on how stale you can tolerate.
  • Event-driven: subscribe to DB change events (Debezium, DB triggers) and delete affected cache keys.
  • Write-invalidate pattern: delete the cache key on write; let the next read repopulate it (safer than write-through when consistency matters).
  • Versioned keys (cache:resource:v{n}:{id}): no invalidation — increment version on schema change. Old keys expire naturally.
  • Cache stampede: when a TTL expires under load, many requests simultaneously miss and hammer the DB. Fix: probabilistic early expiration or mutex locks.
  • Never cache mutable state without a clear invalidation plan — 'just set a short TTL' is not a plan.

Remember this

"Just set a short TTL" is not an invalidation plan — a cache stampede under load hammers the database the moment that TTL expires unless probabilistic early expiration or a mutex lock is already in place.

Multi-Level Cache Architecture

A multi-level design can place an in-process cache inside each app instance, Redis across instances, and a CDN or reverse proxy in front. Each added layer can remove work from the next one, but it also creates another copy whose lifetime and invalidation must be understood.

Trace one product request from the outside in: the CDN checks a public-response key, the app checks a process-local product key, Redis checks the shared key, and only then does Postgres run the query. On a write, invalidate in the opposite direction and decide what happens when one purge fails. If you cannot observe which layer served a response, do not add the next layer yet.

Multi-level cache request path: outside-in CDN → process → Redis → DB
Multi-level cache request path: outside-in CDN → process → Redis → DB

Quick reference

  • L1 (in-process): avoids a network hop but is per-instance and disappears on restart.
  • L2 (Redis): shares hot dynamic data across instances but adds network and operational failure modes.
  • L3 (CDN/reverse proxy): can stop public traffic before it reaches the application.
  • Invalidation across layers: deleting a Redis key doesn't clear in-process caches on other instances. Use Redis Pub/Sub to broadcast invalidation events.
  • Memory limits: set maxmemory on Redis and choose an eviction policy (allkeys-lru for cache workloads).
  • Monitoring: track cache hit rate, eviction rate, memory usage, and key TTL distribution in dashboards.
  • Bottleneck flow: CDN miss high → inspect edge keys/headers; Redis miss high → inspect keys/TTLs; origin slow despite hits → profile serialization or app work; write staleness → audit invalidation acknowledgements.

Remember this

Layer caches from fastest to slowest: in-process → Redis → CDN. Invalidation must cascade through all layers — a key deleted in Redis may still live in process memory.

Key takeaway

Choose a pattern from the failure contract, not from a preferred product: cache-aside for an explicit application fallback, read-through for centralized loaders, coordinated writes when the freshness benefit earns the extra failure modes, and CDN caching only for responses safe to share. A TTL limits how long a forgotten copy can live; it does not replace invalidation.

Practice (20 min): Pick one read-heavy endpoint and implement or diagram its hit, miss, not-found, cache-down, origin-down, and write-invalidation paths; the expected hit returns the same current value without origin work. Intentionally stop the cache and leave one stale copy after a write. Recover through the documented source/error fallback and invalidate every cache layer. Pass when evidence identifies which dependency answered all six paths and no post-write request serves the stale value beyond the stated freshness bound.

Share:

Related Articles

A product page feels instant on the second visit because some layer reused work from the first. The useful beginner ques

Read

In-memory caching is an essential component of high-throughput web architectures, reducing database read load and accele

Read

A Redis GET can be constant-time and still miss its latency target when a large Lua script is ahead of it, the client op

Read

Keep learning

Follow a structured path or browse all courses to go deeper.