?

Day 0544 minPreface+60 XP

Multi-Tier Caching.

4 stages20 code

watch it first · newThe whole lesson in a 19-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

This week, you've learned caching patterns, invalidation strategies, thundering herd prevention, and feed caching. Each focused on a single cache layer.

But production systems don't have just one cache. They have many:

THE REAL WORLD: MULTIPLE CACHE LAYERS
  1. Layer 1: Browser Cache — User request: "Show me product #12345"
    • Hit? Return cached page (0ms)
    • Miss? Continue...
  2. Layer 2: CDN (Edge)
    • Hit? Return from edge server (20ms)
    • Miss? Continue...
  3. Layer 3: API Gateway
    • Hit? Return cached response (5ms)
    • Miss? Continue...
  4. Layer 4: Application Cache (Redis)
    • Hit? Return from Redis (2ms)
    • Miss? Continue...
  5. Layer 5: Database Cache (Buffer Pool)
    • Hit? Return from buffer pool (10ms)
    • Miss? Query disk (50ms)

Each layer serves a purpose. Each layer has different characteristics. Each layer needs different invalidation strategies. Today: How to design caching across ALL these layers.

This is where caching gets complex — and where the real performance wins happen.

ExplainerThe whole lesson: multi-tier caching

A request falls through the browser (0 ms), CDN (20 ms), gateway (5 ms), Redis (2 ms) and the database buffer pool, and each tier holds different data under its own rules: Cache-Control and hashed filenames in the browser, Vary at the CDN, auth checks and rate limits at the gateway, business data in Redis. A $99 → $79 change is invalidated closest to the database first, Redis then gateway then CDN, while browsers wait out max-age; anonymous content is cached everywhere, authenticated responses are private and never cached at the CDN.

Part I · Stage 1 of 4 · 25 min

Part I: Foundations#

Chapter 1: The Caching Hierarchy#

1.1 Understanding the Layers#

DiagramCache layer hierarchy
%% title: Cache layer hierarchy
flowchart TD
  B@{ icon: "sd:browser", label: "Browser Cache<br/>~0ms · hit rate High<br/>capacity Small · Per user" }
  CDN@{ icon: "sd:cdn", label: "CDN (Edge)<br/>10-50ms · hit rate High<br/>capacity Large · Global" }
  GW@{ icon: "sd:gateway", label: "API Gateway Cache<br/>1-5ms · hit rate Medium<br/>capacity Medium · Regional" }
  R@{ icon: "tech:redis", label: "Application Cache (Redis)<br/>1-2ms · hit rate High<br/>capacity Large · Application" }
  QC@{ icon: "sd:cache", label: "Database Buffer Pool<br/>1-10ms · hit rate High<br/>capacity Medium · Database" }
  DB@{ icon: "sd:database", label: "Database (Disk)<br/>10-100ms · hit rate N/A<br/>capacity Large · Database" }
  B --> CDN --> GW --> R --> QC --> DB
  class B client
  class CDN,GW edge
  class R,QC cache
  class DB store

Moving down the hierarchy:

  • Latency increases
  • Data freshness increases
  • Capacity increases
  • Scope broadens
ExplainerMulti-tier caching: the request path and invalidation

A request falls through the browser cache (0 ms), CDN edge (20 ms), API gateway (5 ms), Redis (2 ms) and the database buffer pool (10 ms) before reaching disk (50 ms); the first hit answers. When a price drops from $99 to $79 the write updates the buffer pool, Redis and the gateway are invalidated, the CDN is purged, and browsers keep $99 until max-age expires.

Your turn · checkpoint 1not answered

Place four kinds of data

Where would you cache each of these: product images, a logged-in user's profile, rate-limit counters for API keys, and product details? Name the layer and the one property that decides it.

Think it through, or jot it down. Then compare.

One strong answer

  • Product images: browser and CDN. They're large, the same for everyone and rarely change.
  • A logged-in user's profile: the application cache, and at most the user's own browser (private). Never the CDN, which could serve it to someone else.
  • Rate-limit counters: the API gateway. Every request checks them, and they must be shared across gateway instances.
  • Product details: the application cache (Redis), where events can invalidate them quickly.

1.2 What Belongs at Each Layer#

CONTENT PLACEMENT BY LAYER
  • BROWSER CACHE
    • Static assets (JS, CSS, images)
    • User-specific preferences
    • Recently viewed items
    • API responses with Cache-Control headers
  • CDN (EDGE)
    • Static assets
    • Public API responses (product listings)
    • Marketing pages
    • Media files (images, videos)
    • NOT: Personalized content, authenticated responses
  • API GATEWAY
    • Rate limit counters
    • Authentication tokens (validation cache)
    • Common API responses
    • Request deduplication
    • NOT: User-specific data
  • APPLICATION (REDIS)
    • Session data
    • User profiles
    • Product details
    • Computed aggregates (counts, stats)
    • Feature flags
    • Everything that's read frequently
  • DATABASE
    • Buffer pool (automatic)
    • Query result cache (only some engines; MySQL 8.0 removed it, PostgreSQL has none)
    • Materialized views
    • Prepared statement cache

1.3 Cache Characteristics by Layer#

Layer TTL Range Invalidation Consistency Best For
Browser Minutes-Days Headers, versioned URLs Eventual Static assets, preferences
CDN Seconds-Hours Purge API, TTL Eventual Public content, media
API Gateway Seconds-Minutes TTL, events Eventual Auth, rate limits
Application Seconds-Hours Events, TTL Near real-time Business data
Database Automatic Updated by writes Strong Hot pages and indexes
WorksheetHow the tiers stack up (assumptions labelled)
Value
Product-page API requests at peak (assumed) 100,000/s
CDN hit ratio (assumed) 90% → 10,000/s reach the origin
Gateway cache hit ratio on the rest (assumed) 50% → 5,000/s reach the application
Redis hit ratio (assumed) 95% → 250/s reach the database
Redis alone, no CDN or gateway cache 5% of 100,000 → 5,000/s reach the database
CDN TTL for product APIs (Section 3.2) 60 s → a missed purge leaves an edge serving the old price for up to a minute

Conclusion: each outer tier divides the load on everything behind it, so the database sees 250 queries a second instead of 5,000. The price is that every tier holds its own copy: a change has to be cleared from each one, and the outer tiers' TTLs, not Redis, decide how long a stale price can survive.

1.4 Key Terminology#

Term Definition
Edge CDN servers geographically close to users
Origin Your actual servers (behind CDN)
Cache-Control HTTP header controlling browser/CDN caching
Vary HTTP header for cache key variation
Purge Explicitly removing content from CDN
Stale-while-revalidate Serve stale, refresh async
Cache key Unique identifier for cached content
Hit ratio Percentage of requests served from cache

Chapter 2: Browser Caching#

2.1 HTTP Cache Headers#

HTTP CACHE-CONTROL DIRECTIVES

Response headers that control browser caching:

Header Meaning
Cache-Control: max-age=31536000, immutable Cache for 1 year, never revalidate
Cache-Control: no-cache Cache, but revalidate before using
Cache-Control: no-store Don't cache at all (sensitive data)
Cache-Control: private, max-age=3600 Only browser can cache (not CDN), 1 hour
Cache-Control: public, max-age=86400 Anyone can cache (browser, CDN), 24 hours
Cache-Control: max-age=0, must-revalidate Always check with server before using
Cache-Control: stale-while-revalidate=60 Can serve stale for 60s while refreshing
Your turn · checkpoint 2not answered

A year-long cache and same-day deploys

You want browsers to cache your JavaScript for a year without ever re-checking it, but each deploy must reach users right away. Both seem impossible at once. How do you get both?

Think it through, or jot it down. Then compare.

One strong answer

  • Put a hash of the file's content in its filename, like /app.abc123.js, and serve it with max-age=31536000, immutable.
  • A deploy changes the content, so the filename changes. The page links to /app.def456.js, a URL no browser has cached, so everyone fetches it fresh.
  • The old file can stay cached forever without conflict, because nothing links to it any more.

2.2 Versioned Assets (Cache Busting)#

CACHE BUSTING STRATEGIES

Problem: User has cached old JavaScript · You deploy new JavaScript · User still sees old version!

Solution Example How
Solution 1: Query string versioning /app.js?v=1.2.3 Change version, URL changes, cache misses
Solution 2: Filename hashing (recommended) /app.abc123.js Hash of content in filename · Content changes = filename changes · Old files can stay cached (no conflict)
Solution 3: Path versioning /v2/app.js New version = new path

Implementation:

pythonget_static · 57 lines
# Cache headers in FastAPI

from fastapi import FastAPI, Response, Depends
from fastapi.responses import FileResponse
import hashlib

app = FastAPI()


# Static assets: Long cache with content hash
@app.get("/static/{filename}")
async def get_static(filename: str):
    file_path = f"static/{filename}"
    
    # Generate ETag from content
    with open(file_path, 'rb') as f:
        content_hash = hashlib.md5(f.read()).hexdigest()
    
    return FileResponse(
        file_path,
        headers={
            "Cache-Control": "public, max-age=31536000, immutable",
            "ETag": f'"{content_hash}"'
        }
    )


# API responses: Short cache with revalidation
@app.get("/api/products/{product_id}")
async def get_product(product_id: str, response: Response):
    product = await fetch_product(product_id)
    
    # Cache for 5 minutes, allow stale for 1 minute while revalidating
    response.headers["Cache-Control"] = "public, max-age=300, stale-while-revalidate=60"
    response.headers["ETag"] = f'"{product["version"]}"'
    
    return product


# Personalized content: Private cache only
@app.get("/api/me/profile")
async def get_my_profile(response: Response, user = Depends(get_current_user)):
    profile = await fetch_profile(user.id)
    
    # Only browser can cache, not CDN
    response.headers["Cache-Control"] = "private, max-age=60"
    
    return profile


# Sensitive data: No caching
@app.get("/api/me/payment-methods")
async def get_payment_methods(response: Response):
    # Never cache payment info
    response.headers["Cache-Control"] = "no-store"
    
    return await fetch_payment_methods()

2.3 Conditional Requests (Revalidation)#

CONDITIONAL REQUEST FLOW
  1. First request — Client → GET /api/product/123
    • Server → 200 OK, ETag: "abc123", Cache-Control: max-age=300
  2. After 5 minutes (cache expired) — Client → GET /api/product/123 with If-None-Match: "abc123"
    • If unchanged: Server → 304 Not Modified (no body!) — client uses cached version
    • If changed: Server → 200 OK, ETag: "def456" (full response body)

Benefit: Saves bandwidth when content unchanged

Chapter 3: CDN Caching#

3.1 How CDNs Work#

DiagramCDN architecture
%% title: CDN architecture
flowchart TD
  O["Your Origin<br/>(servers)"]:::core
  E1@{ icon: "sd:cdn", label: "CDN Edge<br/>US-East" }
  E2@{ icon: "sd:cdn", label: "CDN Edge<br/>EU-West" }
  E3@{ icon: "sd:cdn", label: "CDN Edge<br/>Asia-Pacific" }
  U1@{ icon: "sd:users", label: "Users · New York" }
  U2@{ icon: "sd:users", label: "Users · London" }
  U3@{ icon: "sd:users", label: "Users · Tokyo" }
  U4@{ icon: "sd:users", label: "Users · Sydney" }
  O --> E1
  O --> E2
  O --> E3
  E1 --> U1
  E2 --> U2
  E3 --> U3
  E3 --> U4
  class E1,E2,E3 edge
  class U1,U2,U3,U4 client

Request flow:

  1. User in Tokyo requests image
  2. DNS routes to nearest edge (Asia-Pacific)
  3. Edge checks cache:
    • HIT: Return immediately (20ms)
    • MISS: Fetch from origin, cache, return (200ms first time)
  4. Next Tokyo user gets cached version (20ms)

3.2 CDN Cache Configuration#

python44 lines
# CDN configuration example (CloudFront-style)

CDN_CACHE_BEHAVIORS = {
    # Static assets: Aggressive caching
    "/static/*": {
        "ttl_default": 86400 * 365,  # 1 year
        "ttl_min": 86400,
        "ttl_max": 86400 * 365,
        "compress": True,
        "forward_headers": [],  # Don't vary on headers
        "forward_query_strings": False,
        "cache_methods": ["GET", "HEAD"],
    },
    
    # API - Public endpoints: Moderate caching
    "/api/products/*": {
        "ttl_default": 60,  # 1 minute
        "ttl_min": 0,
        "ttl_max": 3600,
        "compress": True,
        "forward_headers": ["Accept", "Accept-Language"],
        "forward_query_strings": True,  # Different params = different cache
        "cache_methods": ["GET"],
    },
    
    # API - Authenticated: No CDN caching
    "/api/me/*": {
        "ttl_default": 0,
        "ttl_min": 0,
        "ttl_max": 0,
        "forward_headers": ["Authorization", "Cookie"],
        "cache_methods": [],  # Don't cache
    },
    
    # Media: Long cache with purge capability
    "/media/*": {
        "ttl_default": 86400 * 7,  # 1 week
        "ttl_min": 3600,
        "ttl_max": 86400 * 30,
        "compress": False,  # Already compressed (images, video)
        "forward_headers": [],
        "forward_query_strings": False,
    },
}
Your turn · checkpoint 3not answered

English for everyone

/api/products returns product names in the language of the Accept-Language header, and the CDN caches it publicly. What does a French visitor see if the response has no Vary header? What goes wrong if you "fix" it with Vary: Cookie?

Think it through, or jot it down. Then compare.

One strong answer

  • No Vary: the CDN keys only on URL and query string, so all languages share one entry. Whichever language was cached first, say English, is served to everyone.
  • Vary: Cookie: every user's cookies differ, so every user gets their own entry and the CDN stops saving anything.
  • Right fix: Vary: Accept-Language, which stores one copy per language.

3.3 CDN Cache Keys and Vary#

CACHE KEY COMPONENTS

Default cache key: URL + Query String — /api/products?category=electronics&sort=price is cached separately from /api/products?category=electronics&sort=name

Vary header adds dimensions
Header Effect
Vary: Accept-Language /api/products (Accept-Language: en) ≠ /api/products (Accept-Language: es)
Vary: Accept-Encoding Gzipped and non-gzipped cached separately
Vary: Cookie DANGEROUS! Every user gets different cache = no caching benefit
EXAMPLE: Language-aware caching

Response:

http3 lines
Cache-Control: public, max-age=3600
Vary: Accept-Language
Content-Language: en

CDN caches separately for: Accept-Language: en · Accept-Language: es · Accept-Language: fr

3.4 CDN Invalidation (Purging)#

pythonCDNPurgeService · 66 lines
# CDN Purge Implementation

import httpx
from typing import List


class CDNPurgeService:
    """
    Service to invalidate CDN cache.
    
    Different CDN providers have different APIs.
    This is a generic implementation.
    """
    
    def __init__(self, cdn_api_url: str, api_key: str):
        self.cdn_api_url = cdn_api_url
        self.api_key = api_key
        self.client = httpx.AsyncClient()
    
    async def purge_url(self, url: str) -> bool:
        """Purge a specific URL from CDN."""
        response = await self.client.post(
            f"{self.cdn_api_url}/purge",
            headers={"Authorization": f"Bearer {self.api_key}"},
            json={"urls": [url]}
        )
        return response.status_code == 200
    
    async def purge_pattern(self, pattern: str) -> bool:
        """Purge URLs matching pattern (e.g., /products/*)."""
        response = await self.client.post(
            f"{self.cdn_api_url}/purge",
            headers={"Authorization": f"Bearer {self.api_key}"},
            json={"pattern": pattern}
        )
        return response.status_code == 200
    
    async def purge_tag(self, tag: str) -> bool:
        """Purge all URLs with a specific cache tag."""
        response = await self.client.post(
            f"{self.cdn_api_url}/purge",
            headers={"Authorization": f"Bearer {self.api_key}"},
            json={"tag": tag}
        )
        return response.status_code == 200


# Integration with product updates
class ProductService:
    def __init__(self, db, cache, cdn_purge: CDNPurgeService):
        self.db = db
        self.cache = cache
        self.cdn = cdn_purge
    
    async def update_product(self, product_id: str, data: dict) -> dict:
        # Update database
        product = await self.db.update_product(product_id, data)
        
        # Invalidate application cache
        await self.cache.delete(f"product:{product_id}")
        
        # Purge CDN
        await self.cdn.purge_url(f"/api/products/{product_id}")
        await self.cdn.purge_pattern(f"/api/products?*")  # List pages
        
        return product

Chapter 4: API Gateway Caching#

4.1 What to Cache at the Gateway#

API GATEWAY CACHE USE CASES
  1. AUTHENTICATION TOKEN VALIDATION — Validate JWT/session token · Cache validation result (short TTL) · Avoid hitting auth service every request
  2. RATE LIMIT COUNTERS — Track requests per API key · Must be fast (every request checks) · Shared across gateway instances
  3. RESPONSE CACHING — Cache entire API responses · Based on URL + headers · Short TTL for dynamic content
  4. REQUEST DEDUPLICATION — Multiple identical requests in flight · Collapse into single backend request · Similar to thundering herd protection

4.2 Gateway Response Caching#

pythonGatewayCache · 140 lines
# API Gateway Caching Implementation

from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
import hashlib
import json
from typing import Optional


class GatewayCache:
    """
    API Gateway response cache.
    
    Caches responses based on:
    - HTTP method
    - URL path
    - Query parameters
    - Selected headers (Accept, Accept-Language)
    """
    
    def __init__(self, redis_client, default_ttl: int = 60):
        self.redis = redis_client
        self.default_ttl = default_ttl
        
        # Paths that should be cached
        self.cacheable_paths = {
            "/api/products": 60,
            "/api/categories": 300,
            "/api/config": 3600,
        }
        
        # Headers that affect cache key
        self.vary_headers = ["Accept", "Accept-Language", "Accept-Encoding"]
    
    def _build_cache_key(self, request: Request) -> str:
        """Build cache key from request."""
        parts = [
            request.method,
            str(request.url.path),
            str(sorted(request.query_params.items())),
        ]
        
        # Add varied headers
        for header in self.vary_headers:
            value = request.headers.get(header, "")
            parts.append(f"{header}:{value}")
        
        key_string = "|".join(parts)
        return f"gateway_cache:{hashlib.sha256(key_string.encode()).hexdigest()}"
    
    def _is_cacheable(self, request: Request) -> bool:
        """Check if request is cacheable."""
        if request.method not in ("GET", "HEAD"):
            return False
        
        # Check if path matches cacheable patterns
        path = request.url.path
        for pattern in self.cacheable_paths:
            if path.startswith(pattern):
                return True
        
        return False
    
    def _get_ttl(self, request: Request) -> int:
        """Get TTL for request."""
        path = request.url.path
        for pattern, ttl in self.cacheable_paths.items():
            if path.startswith(pattern):
                return ttl
        return self.default_ttl
    
    async def get_cached_response(self, request: Request) -> Optional[Response]:
        """Try to get cached response."""
        if not self._is_cacheable(request):
            return None
        
        cache_key = self._build_cache_key(request)
        cached = await self.redis.get(cache_key)
        
        if cached:
            data = json.loads(cached)
            return JSONResponse(
                content=data["body"],
                status_code=data["status_code"],
                headers={
                    **data["headers"],
                    "X-Cache": "HIT",
                    "X-Cache-Key": cache_key[:16]
                }
            )
        
        return None
    
    async def cache_response(
        self,
        request: Request,
        response_body: dict,
        status_code: int,
        headers: dict
    ):
        """Cache a response."""
        if not self._is_cacheable(request):
            return
        
        if status_code != 200:
            return
        
        cache_key = self._build_cache_key(request)
        ttl = self._get_ttl(request)
        
        data = {
            "body": response_body,
            "status_code": status_code,
            "headers": {k: v for k, v in headers.items() if k.lower() not in ("content-length", "transfer-encoding")}
        }
        
        await self.redis.setex(cache_key, ttl, json.dumps(data))


# Middleware for automatic caching
class CacheMiddleware:
    def __init__(self, app, cache: GatewayCache):
        self.app = app
        self.cache = cache
    
    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
        
        request = Request(scope, receive)
        
        # Try cache
        cached_response = await self.cache.get_cached_response(request)
        if cached_response:
            await cached_response(scope, receive, send)
            return
        
        # Not cached - forward to app
        await self.app(scope, receive, send)
Your turn · checkpoint 4not answered

How long does a revoked token work?

The gateway caches "token is valid" results for 60 seconds so it doesn't call the auth service on every request. An admin revokes a token, but nothing deletes its cached result. How long can the token keep working? Why keep that TTL short rather than, say, an hour?

Think it through, or jot it down. Then compare.

One strong answer

  • Up to about 60 seconds: the cached "valid" result is trusted until its TTL runs out.
  • The TTL is the revocation delay. An hour-long TTL would cut auth-service load further, but a revoked or stolen token would keep working for up to an hour.
  • To close the gap entirely, delete the cached result on logout or revoke, using a per-user set of cached keys.

4.3 Authentication Cache#

pythonAuthCache · 71 lines
# Auth Token Validation Cache

class AuthCache:
    """
    Cache authentication token validation results.
    
    Reduces load on auth service by caching valid tokens.
    Short TTL ensures revoked tokens stop working quickly.
    """
    
    def __init__(self, redis_client, auth_service, ttl: int = 60):
        self.redis = redis_client
        self.auth_service = auth_service
        self.ttl = ttl
    
    async def validate_token(self, token: str) -> Optional[dict]:
        """
        Validate token with caching.
        
        Returns user info if valid, None if invalid.
        """
        cache_key = f"auth:{self._hash_token(token)}"
        
        # Check cache
        cached = await self.redis.get(cache_key)
        if cached:
            if cached == "INVALID":
                return None
            return json.loads(cached)
        
        # Validate with auth service
        try:
            user = await self.auth_service.validate(token)
            
            if user:
                # Cache valid result, and remember which keys belong to this
                # user so they can all be revoked without a KEYS scan
                pipe = self.redis.pipeline()
                pipe.setex(cache_key, self.ttl, json.dumps(user))
                pipe.sadd(f"auth_user_keys:{user['id']}", cache_key)
                pipe.expire(f"auth_user_keys:{user['id']}", self.ttl)
                await pipe.execute()
                return user
            else:
                # Cache invalid result (shorter TTL)
                await self.redis.setex(cache_key, 10, "INVALID")
                return None
                
        except Exception as e:
            # Auth service error - don't cache; fail closed (re-raise)
            logger.error(f"Auth service error: {e}")
            raise
    
    def _hash_token(self, token: str) -> str:
        """Hash token for cache key (security)."""
        return hashlib.sha256(token.encode()).hexdigest()
    
    async def invalidate_token(self, token: str):
        """Invalidate a cached token (on logout/revoke)."""
        cache_key = f"auth:{self._hash_token(token)}"
        await self.redis.delete(cache_key)
    
    async def invalidate_user_tokens(self, user_id: str):
        """Invalidate all cached tokens for a user."""
        # Token keys are hashes (auth:{sha256}), so no pattern can find a
        # user's tokens; use the per-user set recorded in validate_token
        set_key = f"auth_user_keys:{user_id}"
        keys = await self.redis.smembers(set_key)
        if keys:
            await self.redis.delete(*keys)
        await self.redis.delete(set_key)
Quick check3 questions · +5 XP each

Browser, CDN and gateway caching

  1. Q1/3 Which Cache-Control header fits a logged-in user's /api/me/profile response?

    Answer & explanation

    Answer: B

    private keeps the CDN and other shared caches from storing it, so one user's profile is never served to another. Vary: Cookie technically separates users, but it means every user gets their own entry, so the CDN gains nothing. The lesson calls this dangerous.

  2. Q2/3 Your JS is served as /app.abc123.js with max-age=31536000, immutable. After a deploy, how do users get the new code without waiting a year?

    Answer & explanation

    Answer: D

    With filename hashing, changed content means a changed filename. The page links to /app.def456.js, which browsers have never seen, so they fetch it fresh. immutable tells the browser not to revalidate at all, so an ETag check never happens for the old URL.

  3. Q3/3 The gateway's auth cache keeps valid token results for 60 seconds. An admin revokes a token, but nobody calls invalidate_token. How long can that token keep working at the gateway?

    Answer & explanation

    Answer: C

    The cached "valid" result is trusted until its TTL runs out, which is why the lesson keeps this TTL short: it bounds how long a revoked token still works. The 10-second TTL applies to cached invalid results, not valid ones.

Chapter 5: Application Cache (Redis)#

5.1 Application Cache Patterns#

We covered this extensively in Days 1-4. Quick recap:

APPLICATION CACHE PATTERNS
  • CACHE-ASIDE — Most common
    • Read: Check cache → Miss → Load DB → Store cache → Return
    • Write: Update DB → Invalidate cache
  • COMPUTED VALUES — Store pre-computed aggregates
    • User's follower count
    • Product's average rating
    • Dashboard statistics
  • SESSION DATA
    • User sessions with sliding expiration
    • Shopping carts
    • Temporary wizard state
  • FEATURE FLAGS
    • Configuration that rarely changes
    • A/B test assignments
    • Gradual rollout percentages
  • RATE LIMITING
    • Request counts per window
    • User quotas
    • API usage tracking

5.2 Layered Cache Keys#

pythonCacheKeyBuilder · 50 lines
# Structured cache keys for multi-tier systems

class CacheKeyBuilder:
    """
    Build structured cache keys for multi-tier caching.
    
    Key format: {prefix}:{version}:{entity}:{id}:{variant}
    
    Example: app:v2:product:12345:en
    """
    
    def __init__(self, prefix: str = "app", version: str = "v1"):
        self.prefix = prefix
        self.version = version
    
    def product(self, product_id: str, locale: str = None) -> str:
        key = f"{self.prefix}:{self.version}:product:{product_id}"
        if locale:
            key += f":{locale}"
        return key
    
    def product_list(self, category: str, page: int, locale: str = None) -> str:
        key = f"{self.prefix}:{self.version}:products:{category}:page{page}"
        if locale:
            key += f":{locale}"
        return key
    
    def user_profile(self, user_id: str) -> str:
        return f"{self.prefix}:{self.version}:user:{user_id}:profile"
    
    def user_feed(self, user_id: str) -> str:
        return f"{self.prefix}:{self.version}:feed:{user_id}"
    
    def session(self, session_id: str) -> str:
        return f"{self.prefix}:session:{session_id}"  # No version - survives deploys
    
    def rate_limit(self, key: str, window: str) -> str:
        return f"{self.prefix}:ratelimit:{key}:{window}"
    
    def invalidation_pattern(self, entity: str, id: str = "*") -> str:
        """Pattern for bulk invalidation."""
        return f"{self.prefix}:{self.version}:{entity}:{id}:*"


# Usage
keys = CacheKeyBuilder(prefix="myapp", version="v3")

# After schema change, bump version to v4
# All v3 keys become orphaned and expire naturally
# No explicit invalidation needed!

Chapter 6: Multi-Tier Invalidation#

6.1 The Invalidation Challenge#

MULTI-TIER INVALIDATION PROBLEM

Product price changes from $99 to $79:

Layer Problem
Layer 1: Browser Users have old price cached · Can't push invalidation to browsers! · Must wait for Cache-Control max-age
Layer 2: CDN Multiple edge servers have old price · Must purge from all edges · Purge takes time to propagate
Layer 3: API Gateway Response cache has old price · Must invalidate gateway cache
Layer 4: Application (Redis) Product cache has old price · Must delete/update Redis key
Layer 5: Database Buffer pool is updated by the write itself · No stale copy to invalidate

Propagation order matters! If CDN still has old price, users get stale data even if application cache is fresh.

6.2 Invalidation Strategies by Layer#

pythonInvalidationResult · 194 lines
# Multi-Tier Invalidation Service

from dataclasses import dataclass
from typing import List, Optional
import asyncio


@dataclass
class InvalidationResult:
    """Result of multi-tier invalidation."""
    success: bool
    browser_invalidated: bool  # Can only suggest via headers
    cdn_invalidated: bool
    gateway_invalidated: bool
    app_cache_invalidated: bool
    errors: List[str]


class MultiTierInvalidationService:
    """
    Coordinates cache invalidation across all tiers.
    
    Order of invalidation:
    1. Application cache (Redis) - fastest
    2. API Gateway cache
    3. CDN - slowest to propagate
    4. Browser - can only influence future requests
    """
    
    def __init__(
        self,
        redis_client,
        gateway_cache,
        cdn_service,
        event_publisher
    ):
        self.redis = redis_client
        self.gateway = gateway_cache
        self.cdn = cdn_service
        self.events = event_publisher
    
    async def invalidate_product(
        self,
        product_id: str,
        eager_cdn_purge: bool = True
    ) -> InvalidationResult:
        """
        Invalidate a product across all cache tiers.
        """
        errors = []
        
        # 1. Application cache (immediate)
        app_success = await self._invalidate_app_cache(product_id, errors)
        
        # 2. API Gateway cache
        gateway_success = await self._invalidate_gateway_cache(product_id, errors)
        
        # 3. CDN
        cdn_success = True
        if eager_cdn_purge:
            cdn_success = await self._invalidate_cdn(product_id, errors)
        else:
            # Queue CDN purge for background processing
            await self.events.publish("cdn-purge-queue", {
                "type": "product",
                "id": product_id
            })
        
        return InvalidationResult(
            success=app_success and gateway_success and cdn_success,
            browser_invalidated=False,  # Can't force browser
            cdn_invalidated=cdn_success,
            gateway_invalidated=gateway_success,
            app_cache_invalidated=app_success,
            errors=errors
        )
    
    async def _invalidate_app_cache(self, product_id: str, errors: List[str]) -> bool:
        """Invalidate Redis cache."""
        try:
            keys_to_delete = [
                f"product:{product_id}",
                f"product_page:{product_id}",
                f"product:{product_id}:*",  # Pattern for variants
            ]
            
            pipe = self.redis.pipeline()
            for key in keys_to_delete:
                if '*' in key:
                    # Pattern delete - scan and delete
                    cursor = 0
                    while True:
                        cursor, keys = await self.redis.scan(cursor, match=key, count=100)
                        if keys:
                            pipe.delete(*keys)
                        if cursor == 0:
                            break
                else:
                    pipe.delete(key)
            
            await pipe.execute()
            return True
            
        except Exception as e:
            errors.append(f"Redis invalidation failed: {e}")
            return False
    
    async def _invalidate_gateway_cache(self, product_id: str, errors: List[str]) -> bool:
        """Invalidate API Gateway cache."""
        try:
            patterns = [
                f"/api/products/{product_id}",
                f"/api/products?*",  # List endpoints
            ]
            
            for pattern in patterns:
                await self.gateway.invalidate_pattern(pattern)
            
            return True
            
        except Exception as e:
            errors.append(f"Gateway invalidation failed: {e}")
            return False
    
    async def _invalidate_cdn(self, product_id: str, errors: List[str]) -> bool:
        """Invalidate CDN cache."""
        try:
            urls = [
                f"/api/products/{product_id}",
                f"/api/products/{product_id}/*",
                f"/products/{product_id}",  # Product page
            ]
            
            results = await asyncio.gather(*[
                self.cdn.purge_url(url) for url in urls
            ], return_exceptions=True)
            
            for i, result in enumerate(results):
                if isinstance(result, Exception):
                    errors.append(f"CDN purge failed for {urls[i]}: {result}")
            
            return all(not isinstance(r, Exception) for r in results)
            
        except Exception as e:
            errors.append(f"CDN invalidation failed: {e}")
            return False
    
    async def invalidate_bulk(
        self,
        entity_type: str,
        entity_ids: List[str]
    ) -> InvalidationResult:
        """
        Bulk invalidation for multiple entities.
        
        More efficient than individual invalidations.
        """
        errors = []
        
        # 1. Application cache - bulk delete
        try:
            pipe = self.redis.pipeline()
            for entity_id in entity_ids:
                pipe.delete(f"{entity_type}:{entity_id}")
            await pipe.execute()
            app_success = True
        except Exception as e:
            errors.append(f"Bulk Redis invalidation failed: {e}")
            app_success = False
        
        # 2. Gateway - invalidate pattern
        try:
            await self.gateway.invalidate_pattern(f"/api/{entity_type}s/*")
            gateway_success = True
        except Exception as e:
            errors.append(f"Gateway invalidation failed: {e}")
            gateway_success = False
        
        # 3. CDN - use cache tags if available
        try:
            await self.cdn.purge_tag(f"{entity_type}")
            cdn_success = True
        except Exception as e:
            errors.append(f"CDN invalidation failed: {e}")
            cdn_success = False
        
        return InvalidationResult(
            success=app_success and gateway_success and cdn_success,
            browser_invalidated=False,
            cdn_invalidated=cdn_success,
            gateway_invalidated=gateway_success,
            app_cache_invalidated=app_success,
            errors=errors
        )

6.3 Event-Driven Multi-Tier Invalidation#

pythonInvalidationEventHandler · 118 lines
# Event-Driven Invalidation System

class InvalidationEventHandler:
    """
    Handles invalidation events for all cache tiers.
    
    Listens to data change events and coordinates
    cache invalidation across tiers.
    """
    
    def __init__(self, invalidation_service: MultiTierInvalidationService):
        self.invalidation = invalidation_service
    
    async def handle_event(self, event: dict):
        """Route event to appropriate handler."""
        event_type = event.get("type")
        
        handlers = {
            "product.updated": self._handle_product_updated,
            "product.deleted": self._handle_product_deleted,
            "product.price_changed": self._handle_price_change,
            "category.updated": self._handle_category_updated,
            "inventory.updated": self._handle_inventory_updated,
            "user.updated": self._handle_user_updated,
            "global.invalidate": self._handle_global_invalidate,
        }
        
        handler = handlers.get(event_type)
        if handler:
            await handler(event)
        else:
            logger.warning(f"Unknown invalidation event type: {event_type}")
    
    async def _handle_product_updated(self, event: dict):
        """Handle product update - full invalidation."""
        product_id = event["product_id"]
        
        result = await self.invalidation.invalidate_product(
            product_id,
            eager_cdn_purge=True
        )
        
        if not result.success:
            logger.error(f"Product invalidation failed: {result.errors}")
    
    async def _handle_price_change(self, event: dict):
        """Handle price change - priority invalidation."""
        product_id = event["product_id"]
        
        # Price changes are high priority - invalidate everywhere immediately
        result = await self.invalidation.invalidate_product(
            product_id,
            eager_cdn_purge=True
        )
        
        # Also invalidate any deals/promotions pages
        await self.invalidation.invalidate_bulk(
            "deals_page",
            ["current", "featured"]
        )
    
    async def _handle_inventory_updated(self, event: dict):
        """Handle inventory update - partial invalidation."""
        product_id = event["product_id"]
        
        # Inventory is usually cached with short TTL
        # Just invalidate application cache, CDN will expire quickly
        await self.invalidation._invalidate_app_cache(product_id, [])
    
    async def _handle_category_updated(self, event: dict):
        """Handle category update - affects multiple products."""
        category_id = event["category_id"]
        
        # Invalidate category cache
        await self.invalidation.invalidate_bulk(
            "category",
            [category_id]
        )
        
        # Invalidate product listings in this category
        await self.invalidation.gateway.invalidate_pattern(
            f"/api/products?category={category_id}*"
        )
        await self.invalidation.cdn.purge_pattern(
            f"/api/products?category={category_id}*"
        )
    
    async def _handle_user_updated(self, event: dict):
        """Handle user update - private cache only."""
        user_id = event["user_id"]
        
        # User data is private - only in app cache
        await self.invalidation.redis.delete(f"user:{user_id}")
        await self.invalidation.redis.delete(f"user:{user_id}:profile")
    
    async def _handle_global_invalidate(self, event: dict):
        """Handle global invalidation - nuclear option."""
        entity_type = event.get("entity_type", "*")
        
        logger.warning(f"Global invalidation triggered for {entity_type}")
        
        # Bump cache version (makes all keys invalid)
        await self.invalidation.redis.incr("cache_version")
        
        # Purge CDN entirely for this entity type
        await self.invalidation.cdn.purge_pattern(f"/api/{entity_type}*")


# Consumer that processes invalidation events
async def run_invalidation_consumer(kafka_consumer, handler: InvalidationEventHandler):
    """Run the invalidation event consumer."""
    async for message in kafka_consumer:
        try:
            event = json.loads(message.value.decode())
            await handler.handle_event(event)
            await kafka_consumer.commit()
        except Exception as e:
            logger.error(f"Failed to process invalidation event: {e}")

Chapter 7: Cache Strategies by Request Type#

7.1 Anonymous vs Authenticated Requests#

CACHING STRATEGY BY AUTH STATUS

ANONYMOUS (Not logged in)

  • Browser: Cache static assets, marketing pages
  • CDN: Cache everything (product pages, listings)
  • Gateway: Cache API responses
  • App: Cache shared data (products, categories)

Key: All anonymous users see same content. Can cache aggressively at all layers.

AUTHENTICATED (Logged in)

  • Browser: Cache static assets only
  • CDN: CAREFUL! Don't cache personalized content
  • Gateway: Cache auth validation, rate limits
  • App: Cache user-specific data

Key: Each user sees different content. CDN caching requires careful Vary headers.

pythonAuthAwareCacheMiddleware · 76 lines
# Auth-Aware Caching

class AuthAwareCacheMiddleware:
    """
    Middleware that applies different caching strategies
    based on authentication status.
    """
    
    def __init__(self, app, cache_service):
        self.app = app
        self.cache = cache_service
    
    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
        
        request = Request(scope, receive)
        is_authenticated = self._is_authenticated(request)
        
        # Set caching strategy based on auth
        if is_authenticated:
            await self._handle_authenticated(request, scope, receive, send)
        else:
            await self._handle_anonymous(request, scope, receive, send)
    
    def _is_authenticated(self, request: Request) -> bool:
        """Check if request is authenticated."""
        return (
            "Authorization" in request.headers or
            "session_id" in request.cookies
        )
    
    async def _handle_anonymous(self, request, scope, receive, send):
        """Handle anonymous request - aggressive caching."""
        # Try CDN-style caching
        cached = await self.cache.get_cached_response(request)
        if cached:
            await cached(scope, receive, send)
            return
        
        # Forward to app with caching headers
        response_started = False
        original_body = []
        
        async def send_wrapper(message):
            nonlocal response_started
            if message["type"] == "http.response.start":
                # Add cache headers for anonymous
                headers = dict(message.get("headers", []))
                # Only GETs that don't set cookies may be shared publicly;
                # a cached Set-Cookie would hand one visitor's session to others
                if scope["method"] == "GET" and b"set-cookie" not in headers:
                    headers[b"cache-control"] = b"public, max-age=60"
                    headers[b"vary"] = b"Accept-Encoding"
                else:
                    headers[b"cache-control"] = b"no-store"
                message["headers"] = list(headers.items())
                response_started = True
            await send(message)
        
        await self.app(scope, receive, send_wrapper)
    
    async def _handle_authenticated(self, request, scope, receive, send):
        """Handle authenticated request - private caching."""
        
        async def send_wrapper(message):
            if message["type"] == "http.response.start":
                headers = dict(message.get("headers", []))
                # Private cache only - no CDN
                headers[b"cache-control"] = b"private, max-age=0, must-revalidate"
                headers[b"vary"] = b"Authorization, Cookie"
                message["headers"] = list(headers.items())
            await send(message)
        
        await self.app(scope, receive, send_wrapper)

7.2 Mobile vs Web Clients#

CACHING STRATEGY BY CLIENT TYPE
Client Capabilities Strategy
WEB BROWSER Has robust caching support · Service Worker for offline · IndexedDB for structured data · Cache API for fine-grained control Use browser capabilities fully · Cache-Control with stale-while-revalidate
MOBILE APP (Native) App-level cache (SQLite, Realm) · Network layer cache (OkHttp, URLCache) · May have offline requirements Server provides cache hints · App decides local caching strategy · ETag/Last-Modified for efficient sync
MOBILE WEB Limited storage · Unreliable network · High latency sensitivity Minimize payload · Aggressive server-side caching · Short TTLs with efficient revalidation

7.3 API Response Caching Decisions#

pythonResponseCachePolicy · 109 lines
# Response Caching Decision Tree

class ResponseCachePolicy:
    """
    Determines caching policy for API responses.
    """
    
    def get_cache_headers(
        self,
        request: Request,
        response_data: dict,
        endpoint: str
    ) -> dict:
        """Determine appropriate cache headers."""
        
        # Start with defaults
        headers = {
            "Cache-Control": "no-cache",
            "Vary": "Accept, Accept-Encoding"
        }
        
        # Check authentication
        if self._is_authenticated(request):
            # Authenticated: Private cache only
            headers["Cache-Control"] = "private, max-age=0"
            headers["Vary"] += ", Authorization"
            return headers
        
        # Anonymous: Apply endpoint-specific rules
        policy = self._get_endpoint_policy(endpoint)
        
        if policy["cacheable"]:
            directives = [
                "public",
                f"max-age={policy['max_age']}",
            ]
            
            if policy.get("stale_while_revalidate"):
                directives.append(
                    f"stale-while-revalidate={policy['stale_while_revalidate']}"
                )
            
            if policy.get("immutable"):
                directives.append("immutable")
            
            headers["Cache-Control"] = ", ".join(directives)
            
            # Add ETag for conditional requests
            if "version" in response_data:
                headers["ETag"] = f'"{response_data["version"]}"'
        
        return headers
    
    def _get_endpoint_policy(self, endpoint: str) -> dict:
        """Get caching policy for endpoint."""
        policies = {
            "/api/products": {
                "cacheable": True,
                "max_age": 60,
                "stale_while_revalidate": 30,
            },
            "/api/products/{id}": {
                "cacheable": True,
                "max_age": 300,
                "stale_while_revalidate": 60,
            },
            "/api/categories": {
                "cacheable": True,
                "max_age": 3600,
                "stale_while_revalidate": 300,
            },
            "/api/search": {
                "cacheable": True,
                "max_age": 30,  # Short for search results
            },
            "/api/cart": {
                "cacheable": False,  # User-specific
            },
            "/api/checkout": {
                "cacheable": False,  # Sensitive
            },
        }
        
        # Match endpoint to policy
        for pattern, policy in policies.items():
            if self._matches_pattern(endpoint, pattern):
                return policy
        
        return {"cacheable": False}
    
    def _matches_pattern(self, endpoint: str, pattern: str) -> bool:
        """Simple pattern matching for endpoints."""
        if "{" not in pattern:
            return endpoint.startswith(pattern)
        
        # Handle patterns like /api/products/{id}
        pattern_parts = pattern.split("/")
        endpoint_parts = endpoint.split("/")
        
        if len(pattern_parts) != len(endpoint_parts):
            return False
        
        for p, e in zip(pattern_parts, endpoint_parts):
            if p.startswith("{") and p.endswith("}"):
                continue  # Wildcard match
            if p != e:
                return False
        
        return True
Quick check3 questions · +5 XP each

Invalidating across tiers

  1. Q1/3 After a price change you delete the product's Redis key and confirm Redis now holds the new price. Users still see the old price. What is the most likely reason?

    Answer & explanation

    Answer: C

    A hit at an outer layer answers the request before it ever reaches the application cache, so a fresh Redis entry doesn't help until those layers are purged or expire. The database's own buffer pool is updated by the write itself; the outer layers are not.

  2. Q2/3 Which lever actually limits how long a browser can show an outdated price?

    Answer & explanation

    Answer: A

    You can't push invalidation into a browser cache; it trusts the response until max-age expires. That's why the lesson's service marks browser_invalidated as always false. CDN purges and version bumps only affect server-side layers.

  3. Q3/3 In the lesson's CacheKeyBuilder, session keys leave out the version segment that product and profile keys include. Why?

    Answer & explanation

    Answer: D

    Bumping v3 to v4 makes every versioned key miss at once, which is fine for cached data but would log every user out if sessions were included. Keeping sessions unversioned separates "safe to throw away" data from state users depend on.

Part II · Stage 2 of 4 · 9 min

Part II: Production Implementation#

Chapter 8: Complete Multi-Tier Cache System#

Note: the samples call fetch_one, as in the databases library (or a thin wrapper with that method). With asyncpg the equivalent is fetchrow, which returns a Record.

pythonMultiTierCacheConfig · 502 lines
# Complete Multi-Tier Cache System

import asyncio
import hashlib
import json
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional, Any
from datetime import datetime, timezone
from enum import Enum

logger = logging.getLogger(__name__)


# =============================================================================
# Configuration
# =============================================================================

@dataclass
class MultiTierCacheConfig:
    """Configuration for multi-tier cache system."""
    
    # Application cache (Redis)
    app_cache_default_ttl: int = 300
    app_cache_max_ttl: int = 86400
    
    # Gateway cache
    gateway_cache_enabled: bool = True
    gateway_cache_ttl: int = 60
    
    # CDN
    cdn_enabled: bool = True
    cdn_default_ttl: int = 60
    cdn_purge_on_update: bool = True
    
    # Browser
    browser_cache_default: int = 0
    browser_cache_static: int = 31536000
    
    # Invalidation
    async_invalidation: bool = True
    invalidation_queue: str = "cache-invalidation"


class CacheLayer(Enum):
    BROWSER = "browser"
    CDN = "cdn"
    GATEWAY = "gateway"
    APPLICATION = "application"
    DATABASE = "database"


# =============================================================================
# Cache Layer Interfaces
# =============================================================================

class ICacheLayer:
    """Interface for cache layers."""
    
    async def get(self, key: str) -> Optional[Any]:
        raise NotImplementedError
    
    async def set(self, key: str, value: Any, ttl: int = None) -> bool:
        raise NotImplementedError
    
    async def delete(self, key: str) -> bool:
        raise NotImplementedError
    
    async def delete_pattern(self, pattern: str) -> int:
        raise NotImplementedError


class ApplicationCache(ICacheLayer):
    """Redis-based application cache."""
    
    def __init__(self, redis_client, config: MultiTierCacheConfig):
        self.redis = redis_client
        self.config = config
    
    async def get(self, key: str) -> Optional[Any]:
        try:
            value = await self.redis.get(key)
            return json.loads(value) if value else None
        except Exception as e:
            logger.warning(f"App cache get error: {e}")
            return None
    
    async def set(self, key: str, value: Any, ttl: int = None) -> bool:
        ttl = min(ttl or self.config.app_cache_default_ttl, self.config.app_cache_max_ttl)
        try:
            await self.redis.setex(key, ttl, json.dumps(value, default=str))
            return True
        except Exception as e:
            logger.warning(f"App cache set error: {e}")
            return False
    
    async def delete(self, key: str) -> bool:
        try:
            await self.redis.delete(key)
            return True
        except Exception as e:
            logger.warning(f"App cache delete error: {e}")
            return False
    
    async def delete_pattern(self, pattern: str) -> int:
        deleted = 0
        try:
            cursor = 0
            while True:
                cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
                if keys:
                    await self.redis.delete(*keys)
                    deleted += len(keys)
                if cursor == 0:
                    break
        except Exception as e:
            logger.warning(f"App cache pattern delete error: {e}")
        return deleted


class CDNCache(ICacheLayer):
    """CDN cache layer (via API)."""
    
    def __init__(self, cdn_client, config: MultiTierCacheConfig):
        self.cdn = cdn_client
        self.config = config
    
    async def get(self, key: str) -> Optional[Any]:
        # CDN is transparent - we don't read from it directly
        return None
    
    async def set(self, key: str, value: Any, ttl: int = None) -> bool:
        # CDN caching is controlled by headers, not direct sets
        return True
    
    async def delete(self, key: str) -> bool:
        if not self.config.cdn_enabled:
            return True
        try:
            return await self.cdn.purge_url(key)
        except Exception as e:
            logger.warning(f"CDN purge error: {e}")
            return False
    
    async def delete_pattern(self, pattern: str) -> int:
        if not self.config.cdn_enabled:
            return 0
        try:
            result = await self.cdn.purge_pattern(pattern)
            return 1 if result else 0
        except Exception as e:
            logger.warning(f"CDN pattern purge error: {e}")
            return 0


# =============================================================================
# Multi-Tier Cache Manager
# =============================================================================

class MultiTierCacheManager:
    """
    Manages caching across all tiers.
    
    Provides unified interface for:
    - Reading from multiple tiers
    - Writing to appropriate tiers
    - Invalidating across all tiers
    """
    
    def __init__(
        self,
        app_cache: ApplicationCache,
        cdn_cache: CDNCache,
        gateway_cache: Optional[ICacheLayer],
        event_publisher,
        config: MultiTierCacheConfig
    ):
        self.app_cache = app_cache
        self.cdn_cache = cdn_cache
        self.gateway_cache = gateway_cache
        self.events = event_publisher
        self.config = config
        
        # Layer order for reads (fastest first)
        self.read_layers = [self.app_cache]
        
        # Layer order for invalidation (innermost first, so outer layers
        # refill from fresh data)
        self.invalidation_layers = [
            self.app_cache,
            self.gateway_cache,
            self.cdn_cache,
        ]
    
    async def get(
        self,
        key: str,
        fetch_func: callable = None,
        ttl: int = None
    ) -> Optional[Any]:
        """
        Get value from cache hierarchy.
        
        Checks each layer in order, returns first hit.
        On miss, optionally fetches and caches.
        """
        # Try each read layer
        for layer in self.read_layers:
            if layer:
                value = await layer.get(key)
                if value is not None:
                    return value
        
        # All layers missed
        if fetch_func is None:
            return None
        
        # Fetch from source
        value = await fetch_func()
        
        if value is not None:
            # Cache in application layer
            await self.app_cache.set(key, value, ttl)
        
        return value
    
    async def set(
        self,
        key: str,
        value: Any,
        ttl: int = None,
        layers: List[CacheLayer] = None
    ) -> bool:
        """
        Set value in specified cache layers.
        
        By default, only sets in application layer.
        CDN caching is typically done via headers.
        """
        layers = layers or [CacheLayer.APPLICATION]
        success = True
        
        for layer in layers:
            if layer == CacheLayer.APPLICATION:
                success = success and await self.app_cache.set(key, value, ttl)
        
        return success
    
    async def invalidate(
        self,
        keys: List[str],
        patterns: List[str] = None,
        cdn_urls: List[str] = None,
        sync: bool = None
    ) -> bool:
        """
        Invalidate cache across all tiers.
        
        Args:
            keys: Specific keys to invalidate
            patterns: Patterns to match for bulk invalidation
            cdn_urls: CDN URLs/patterns to purge
            sync: Force synchronous invalidation
        """
        sync = sync if sync is not None else not self.config.async_invalidation
        
        if sync:
            return await self._invalidate_sync(keys, patterns, cdn_urls)
        else:
            return await self._invalidate_async(keys, patterns, cdn_urls)
    
    async def _invalidate_sync(
        self,
        keys: List[str],
        patterns: List[str] = None,
        cdn_urls: List[str] = None
    ) -> bool:
        """Synchronous invalidation across all tiers."""
        success = True
        
        # Application cache
        for key in keys:
            success = success and await self.app_cache.delete(key)
        
        for pattern in (patterns or []):
            await self.app_cache.delete_pattern(pattern)
        
        # Gateway cache
        if self.gateway_cache:
            for key in keys:
                await self.gateway_cache.delete(key)
            for pattern in (patterns or []):
                await self.gateway_cache.delete_pattern(pattern)
        
        # CDN
        for url in (cdn_urls or []):
            success = success and await self.cdn_cache.delete(url)
        
        return success
    
    async def _invalidate_async(
        self,
        keys: List[str],
        patterns: List[str] = None,
        cdn_urls: List[str] = None
    ) -> bool:
        """Queue invalidation for async processing."""
        event = {
            "type": "cache.invalidate",
            "keys": keys,
            "patterns": patterns or [],
            "cdn_urls": cdn_urls or [],
            "timestamp": datetime.now(timezone.utc).isoformat()
        }
        
        # Invalidate app cache immediately (fast)
        for key in keys:
            await self.app_cache.delete(key)
        
        # Queue the rest for async processing
        await self.events.publish(self.config.invalidation_queue, event)
        
        return True
    
    def get_cache_headers(
        self,
        ttl: int,
        private: bool = False,
        vary: List[str] = None,
        etag: str = None
    ) -> Dict[str, str]:
        """
        Generate HTTP cache headers.
        
        Call this when building responses to control
        browser and CDN caching.
        """
        directives = []
        
        if private:
            directives.append("private")
        else:
            directives.append("public")
        
        directives.append(f"max-age={ttl}")
        
        if ttl > 0:
            # Add stale-while-revalidate for better UX
            swr = min(ttl // 2, 60)
            directives.append(f"stale-while-revalidate={swr}")
        
        headers = {
            "Cache-Control": ", ".join(directives)
        }
        
        if vary:
            headers["Vary"] = ", ".join(vary)
        
        if etag:
            headers["ETag"] = f'"{etag}"'
        
        return headers


# =============================================================================
# Repository with Multi-Tier Caching
# =============================================================================

class CachedProductRepository:
    """
    Product repository with multi-tier caching.
    
    Demonstrates integration of cache manager with business logic.
    """
    
    def __init__(
        self,
        db_client,
        cache_manager: MultiTierCacheManager
    ):
        self.db = db_client
        self.cache = cache_manager
    
    async def get_product(self, product_id: str) -> Optional[dict]:
        """Get product with multi-tier caching."""
        cache_key = f"product:{product_id}"
        
        return await self.cache.get(
            key=cache_key,
            fetch_func=lambda: self._fetch_product(product_id),
            ttl=300
        )
    
    async def _fetch_product(self, product_id: str) -> Optional[dict]:
        """Fetch product from database."""
        row = await self.db.fetch_one(
            "SELECT * FROM products WHERE id = $1",
            product_id
        )
        return dict(row) if row else None
    
    async def update_product(
        self,
        product_id: str,
        data: dict
    ) -> Optional[dict]:
        """Update product with cache invalidation."""
        
        # Update database
        product = await self.db.fetch_one(
            """
            UPDATE products 
            SET name = $2, price = $3, updated_at = NOW()
            WHERE id = $1
            RETURNING *
            """,
            product_id, data.get("name"), data.get("price")
        )
        
        if not product:
            return None
        
        # Invalidate all cache tiers
        await self.cache.invalidate(
            keys=[
                f"product:{product_id}",
                f"product_page:{product_id}",
            ],
            patterns=[
                f"products:*",  # Product lists
            ],
            cdn_urls=[
                f"/api/products/{product_id}",
                f"/api/products?*",
                f"/products/{product_id}",
            ]
        )
        
        return dict(product)
    
    async def get_products_list(
        self,
        category: str = None,
        page: int = 1,
        limit: int = 20
    ) -> dict:
        """Get product list with caching."""
        cache_key = f"products:list:{category or 'all'}:page{page}"
        
        return await self.cache.get(
            key=cache_key,
            fetch_func=lambda: self._fetch_products_list(category, page, limit),
            ttl=60  # Short TTL for lists
        )
    
    async def _fetch_products_list(
        self,
        category: str,
        page: int,
        limit: int
    ) -> dict:
        """Fetch product list from database."""
        offset = (page - 1) * limit
        
        if category:
            rows = await self.db.fetch(
                """
                SELECT * FROM products 
                WHERE category = $1 
                ORDER BY created_at DESC 
                LIMIT $2 OFFSET $3
                """,
                category, limit, offset
            )
        else:
            rows = await self.db.fetch(
                """
                SELECT * FROM products 
                ORDER BY created_at DESC 
                LIMIT $1 OFFSET $2
                """,
                limit, offset
            )
        
        return {
            "products": [dict(row) for row in rows],
            "page": page,
            "limit": limit
        }
    
    def get_response_headers(
        self,
        product: dict,
        authenticated: bool
    ) -> dict:
        """Get cache headers for product response."""
        return self.cache.get_cache_headers(
            ttl=60 if not authenticated else 0,
            private=authenticated,
            vary=["Accept-Encoding"] + (["Authorization"] if authenticated else []),
            etag=str(product.get("updated_at", ""))
        )
Quick check3 questions · +5 XP each

The multi-tier cache manager

  1. Q1/3 With async_invalidation = True, update_product calls invalidate() and it returns. Which layer is guaranteed to be cleared at that moment?

    Answer & explanation

    Answer: B

    The async path deletes app-cache keys inline (fast and local), then publishes an event so slower gateway and CDN purges don't block the write. That keeps updates fast, at the cost of a short window where outer layers may still serve the old value.

  2. Q2/3 The manager's read_layers list contains only the application cache, not the CDN or browser. Why?

    Answer & explanation

    Answer: A

    CDNs and browsers are transparent caches: you influence them with Cache-Control and purge them via APIs, but your code never reads from them. The lesson's CDNCache.get returns None for exactly this reason.

  3. Q3/3 The repository sets each product's ETag from its updated_at. A browser revalidates a product that hasn't changed since it was cached. What does it get back?

    Answer & explanation

    Answer: C

    The browser sends If-None-Match with the old ETag; an unchanged updated_at means the same ETag, so the server answers 304 and saves the payload. A new ETag and full body only come back when the product actually changed.

Part III · Stage 3 of 4 · 4 min

Part III: Real-World Application#

Chapter 9: Case Studies#

9.1 Case Study: Netflix — Multi-Region Multi-Tier#

DiagramNetflix caching architecture
%% title: Netflix caching architecture
flowchart TD
  subgraph CL["Client"]
    direction TB
    BC@{ icon: "sd:browser", label: "Browser/App Cache<br/>Video player state<br/>Recently watched<br/>UI preferences" }
  end
  subgraph OC["CDN (Open Connect)"]
    direction TB
    CDN@{ icon: "sd:cdn", label: "Video content (actual files)<br/>ISP-embedded servers<br/>All video traffic served by Open Connect" }
  end
  subgraph AWS["AWS region"]
    direction TB
    EV@{ icon: "sd:cache", label: "EVCache (Memcached-based)<br/>Session data · User profiles<br/>Recommendations<br/>Zone-aware replication" }
    CS@{ icon: "tech:cassandra", label: "Cassandra<br/>Persistent storage<br/>Cross-region replication" }
  end
  CL --> OC
  OC --> AWS
  class BC client
  class CDN edge
  class EV cache
  class CS store

Key decisions:

  1. VIDEO on CDN - Netflix says Open Connect delivers 100% of its video traffic
  2. METADATA in EVCache - User profiles, preferences
  3. CASSANDRA for durability - Cross-region
  4. Zone-aware - Survive AZ failures

9.2 Case Study: E-commerce Multi-Tier on AWS (illustrative)#

Amazon's own retail stack isn't public; this is a typical AWS reference-style layout.

AWS-STYLE E-COMMERCE CACHING — Product Page Request Flow
  • 1. CLOUDFRONT (CDN)
    • Static assets (JS, CSS, images)
    • Product images
    • Some API responses (product details)
  • 2. APPLICATION LOAD BALANCER
    • SSL termination
    • Request routing
  • 3. MICROSERVICES
    • Product Service
      • ElastiCache (Redis)
        • Product details
        • Price (short TTL)
    • Inventory Service
      • DynamoDB DAX (DynamoDB Accelerator)
        • Real-time inventory
    • Recommendations Service
      • ElastiCache (Redis)
        • Pre-computed recommendations
    • Reviews Service
      • ElastiCache (Memcached)
        • Aggregated reviews
  • 4. DATABASES
    • DynamoDB — Product catalog
    • Aurora — Orders, users
    • Neptune — Product graph
  • Key insight
    • Each microservice has its OWN cache
    • Different cache tech for different needs
    • Redis for complex data, Memcached for simple

9.3 Summary: Industry Patterns#

Company CDN Usage App Cache Key Innovation
Netflix Video content EVCache Zone-aware caching
AWS-style e-commerce Static + API ElastiCache + DAX Per-service caching
Cloudflare Everything Workers KV Edge compute
Facebook Static TAO + Memcache Graph-aware cache
Twitter Media Redis Timeline caching

Chapter 10: Common Mistakes#

10.1 Mistake 1: Caching Authenticated Responses at CDN#

Caching authenticated responses at CDN

WRONG: CDN caches user-specific data

pythonget_profile · 8 lines
# API returns user's profile
@app.get("/api/profile")
async def get_profile(user = Depends(get_current_user)):
    profile = await fetch_profile(user.id)
    return profile  # No Cache-Control header!

# CDN caches this response
# Next user gets PREVIOUS user's profile!

CORRECT: Mark authenticated responses as private

pythonget_profile · 9 lines
@app.get("/api/profile")
async def get_profile(response: Response, user = Depends(get_current_user)):
    profile = await fetch_profile(user.id)

    # Don't cache at CDN
    response.headers["Cache-Control"] = "private, no-store"
    response.headers["Vary"] = "Authorization"

    return profile

10.2 Mistake 2: Forgetting to Vary on Important Headers#

Forgetting to Vary on important headers

WRONG: Cache without proper Vary headers

pythonget_products · 9 lines
# API returns different content based on Accept-Language
@app.get("/api/products")
async def get_products(request: Request):
    lang = request.headers.get("Accept-Language", "en")
    products = await fetch_products(lang)
    return products  # Missing Vary header!

# CDN caches English response
# French user gets English!

CORRECT: Vary on content-affecting headers

pythonget_products · 9 lines
@app.get("/api/products")
async def get_products(request: Request, response: Response):
    lang = request.headers.get("Accept-Language", "en")
    products = await fetch_products(lang)

    response.headers["Vary"] = "Accept-Language, Accept-Encoding"
    response.headers["Cache-Control"] = "public, max-age=60"

    return products
Your turn · checkpoint 5not answered

Purge the CDN first?

A price changes. Your code updates the database, purges the product from the CDN, then deletes the Redis key. Right afterwards the CDN is serving the old price again. What happened, and in what order should the layers be cleared?

Think it through, or jot it down. Then compare.

One strong answer

  • A request arrived between the purge and the Redis delete. The CDN missed, went to the origin, and the origin answered from Redis, which still held the old price. The CDN cached that stale answer again.
  • Clear the layer closest to the database first: Redis, then the gateway, then the CDN. Whatever an outer layer refetches is then already fresh.

10.3 Mistake 3: Invalidating in Wrong Order#

Invalidating in wrong order

WRONG: Invalidate CDN before app cache

pythonupdate_product · 13 lines
async def update_product(product_id: str, data: dict):
    # Update database
    await db.update(product_id, data)

    # Invalidate CDN first
    await cdn.purge(f"/api/products/{product_id}")

    # Then app cache
    await redis.delete(f"product:{product_id}")

    # PROBLEM: CDN fetches from origin between purge and redis delete
    # Origin still has old data in Redis!
    # CDN re-caches stale data!

CORRECT: Invalidate closest to database first

pythonupdate_product · 12 lines
async def update_product(product_id: str, data: dict):
    # Update database
    await db.update(product_id, data)

    # First: App cache (closest to DB)
    await redis.delete(f"product:{product_id}")

    # Second: Gateway cache
    await gateway.invalidate(f"/api/products/{product_id}")

    # Last: CDN (farthest from DB)
    await cdn.purge(f"/api/products/{product_id}")

10.4 Mistake 4: Not Versioning Cache Keys#

Not versioning cache keys

WRONG: Unversioned cache keys

python6 lines
# Deploy v1
await redis.set("product:123", product_v1_schema)

# Deploy v2 (schema changed!)
data = await redis.get("product:123")
# Crash! Old schema doesn't have new fields

CORRECT: Version in cache key

pythoncache_key · 10 lines
CACHE_VERSION = "v3"

def cache_key(entity: str, id: str) -> str:
    return f"{CACHE_VERSION}:{entity}:{id}"

# Deploy v3
await redis.set(cache_key("product", "123"), product_v3_schema)

# On v4 deploy, just change CACHE_VERSION
# Old keys expire naturally, no crash

10.5 Mistake Checklist#

10.5 Mistake Checklist0/7 ready
  • Authenticated responses marked private — No CDN caching of user data
  • Vary headers set correctly — For language, encoding, auth
  • Invalidation order correct — App cache → Gateway → CDN
  • Cache keys versioned — Survives schema changes
  • CDN purge is async — Don't block on CDN API
  • Static assets have long TTL — With content hash in filename
  • Sensitive data not cached — no-store for PII, payment
Quick check3 questions · +5 XP each

Multi-tier in the real world and common mistakes

  1. Q1/3 A product update purges the CDN first, then deletes the Redis key. Right after, the CDN is serving the old product again. What happened?

    Answer & explanation

    Answer: D

    Invalidate closest to the database first (Redis, then gateway, then CDN), so whatever an outer layer refetches is already fresh. Browsers never push data upstream, and the purge did work; it was undone by the stale refill.

  2. Q2/3 /api/products returns localized names based on Accept-Language and is cached publicly at the CDN without a Vary header. What do French users see?

    Answer & explanation

    Answer: B

    Without Vary: Accept-Language, all languages share one cache entry, so whichever response was cached first is served to everyone. Adding the Vary header makes the CDN store a separate copy per language.

  3. Q3/3 Netflix serves video files from its Open Connect CDN but keeps profiles and recommendations in EVCache. Why split them this way?

    Answer & explanation

    Answer: A

    Shared, heavy, static content belongs at the edge (Netflix says Open Connect delivers 100% of its video traffic); personalized metadata belongs in an application cache close to the services. Profiles are cached, just not at the CDN, which shouldn't hold per-user data.

Part IV · Stage 4 of 4 · 5 min

Part IV: Interview Preparation#

Chapter 11: Interview Tips#

❯ Say this

11.1 Key Phrases#

Key phrases
INTRODUCING MULTI-TIER

"For a high-traffic system, I'd design caching at multiple tiers: CDN for static content and public APIs, application cache in Redis for business data, and proper Cache-Control headers for browser caching. Each tier serves a different purpose and has different invalidation needs."

EXPLAINING CDN

"The CDN handles static assets with long TTLs—CSS, JS, images. For API responses, I'd cache public endpoints like product listings at the CDN with short TTLs, but mark authenticated endpoints as private to prevent one user's data from being served to another."

ON INVALIDATION

"Invalidation order matters. When data changes, I invalidate closest to the database first—Redis, then gateway cache, then CDN. This prevents the CDN from re-caching stale data if it fetches from an origin that still has cached old data."

ON CONSISTENCY

"Multi-tier caching means accepting eventual consistency. Browser cache might be stale for minutes, CDN for seconds. For critical data like inventory or pricing, I'd use shorter TTLs and event-driven invalidation to minimize the staleness window."

11.2 Common Questions#

Question Good Answer
"How do you handle cache invalidation at the CDN?" "CDN providers have purge APIs. When data changes, I'd publish an invalidation event, and a consumer calls the CDN purge API. For bulk updates, I'd use cache tags—tag all product pages with 'products', then purge by tag."
"How do you prevent caching user-specific data at CDN?" "Set Cache-Control: private and Vary: Authorization. The private directive tells CDN not to cache. Vary ensures different auth tokens get different cache entries if somehow cached."
"What about browser caching for SPAs?" "I'd use content-hashed filenames for JS/CSS with immutable cache headers (1 year TTL). The HTML entry point gets no-cache with ETag—browser always checks, but gets 304 Not Modified if unchanged."
"How do you debug cache issues?" "Check X-Cache headers from CDN (HIT/MISS). Log cache hit ratios at each tier. Use request IDs to trace a request through all layers. Monitor cache eviction rates and TTL distributions."

Chapter 12: Mock Interview#

Scenario: Design Caching for an E-commerce Platform#

Transcript
Interviewer

"We're building an e-commerce platform. Walk me through how you'd design the caching architecture."

You

"I'd approach this with multiple cache tiers, each serving a specific purpose. Let me walk through from user to database.

Browser caching: For static assets—CSS, JavaScript, images—I'd use content-hashed filenames with immutable cache headers. app.abc123.js gets cached for a year. When we deploy new code, the filename changes, so users get fresh assets without explicit invalidation.

For HTML pages in a single-page app, I'd set Cache-Control: no-cache with ETag. The browser always checks, but gets a 304 Not Modified if nothing changed—saves bandwidth.

Transcript
Interviewer

"What about the CDN layer?"

You

"The CDN is critical for performance. I'd cache:

  1. All static assets with long TTLs, served from edge locations globally.

  2. Public API responses like product listings and category pages with short TTLs—maybe 60 seconds. These are the same for all anonymous users, so CDN caching is highly effective.

  3. Product images with long TTLs. When a product image changes, we'd use a new URL rather than purging.

I would NOT cache authenticated endpoints at the CDN. Any request with an Authorization header gets Cache-Control: private, ensuring user-specific data stays private.

For Vary headers, I'd include Accept-Encoding (for compression) and Accept-Language if we serve localized content.

Transcript
Interviewer

"How do you handle product price changes?"

You

"Price changes need fast propagation. Here's the flow:

  1. Database update: Price changes from $99 to $79.

  2. Application cache invalidation: Immediately delete product:123 from Redis. This is fast, synchronous.

  3. CDN purge: Call the CDN API to purge /api/products/123 and any product listing pages. This is async—I'd queue it to not block the price update.

  4. Browser cache: Can't force invalidation, but with 60-second TTL on product pages, staleness is bounded.

For flash sales where thousands of prices change at once, I'd use CDN cache tags. All product responses include a Cache-Tag: products header. To invalidate all products, I purge by tag—one API call instead of thousands.

Transcript
Interviewer

"What about the application cache layer?"

You

"Redis serves as the application cache. I'd cache:

  • Product details: Full product objects with 5-minute TTL.
  • Inventory counts: Very short TTL (30 seconds) or event-driven invalidation since inventory changes frequently.
  • User sessions: With sliding expiration.
  • Computed values: Like product ratings, category counts.

I'd use the cache-aside pattern—check cache, miss goes to database, populate cache on return. For inventory, I might use read-through with very short TTL since accuracy matters.

Transcript
Interviewer

"How do you ensure consistency across these tiers?"

You

"Perfect consistency across all tiers is impractical—it would eliminate the performance benefits. Instead, I'd design for bounded staleness:

  • CDN: 60-second max staleness for product data
  • Redis: 5-minute max, but event-driven invalidation makes it usually seconds
  • Browser: 60-second max for API data, longer for static assets

For critical paths like checkout, I'd bypass caching entirely and read from the database. A customer should never be charged a different price than what they saw.

The invalidation order is crucial: Redis first, then CDN. If I purge CDN before Redis, the CDN might fetch from origin, get the old cached value from Redis, and re-cache stale data."

Interviewer

"How would you monitor this system?"

You

"Key metrics at each tier:

CDN Hit ratio (should be >95% for static assets), origin fetch latency, purge success rate.

Redis Hit ratio, memory usage, eviction rate, latency percentiles.

Database Query rate (should be low if caching is effective), slow queries.

I'd add X-Cache headers to responses indicating HIT/MISS at each layer, making debugging easier. And I'd alert on hit ratio drops—often indicates invalidation bugs or traffic pattern changes."

Summary#

DAY 5 KEY TAKEAWAYS
THE CACHE HIERARCHY
  • Browser → CDN → Gateway → Application → Database
  • Each layer has different characteristics
  • Moving down: higher latency, better freshness
WHAT BELONGS WHERE
Layer Cache here
Browser Static assets (with content hashing) · User preferences · Short-lived API responses
CDN Static assets · Public API responses · Media files · NOT: Authenticated/personalized content
API Gateway Auth token validation · Rate limit counters · Request deduplication
Application (Redis) Business data · Sessions · Computed values · Feature flags
INVALIDATION ORDER

App Cache → Gateway → CDN → (Browser via TTL). Closest to database first!

KEY PRINCIPLES
  • Private for authenticated responses
  • Vary on content-affecting headers
  • Version cache keys for schema changes
  • Async CDN purge (don't block updates)
  • Bounded staleness, not perfect consistency
CACHE HEADERS
  • Cache-Control: public/private, max-age, stale-while-revalidate
  • Vary: Accept-Encoding, Accept-Language, Authorization
  • ETag: For conditional requests

Further Reading#

Official Documentation#

Engineering Blogs#

Books#

  • Book

    "High Performance Browser Networking" by Ilya Grigorik

  • Book

    "Web Scalability for Startup Engineers" by Artur Ejsmont

This completes Week 4: Caching — Beyond "Just Add Redis"

You've learned:

  • Read

    Day 1: The four caching patterns

  • Read

    Day 2: Invalidation strategies

  • Read

    Day 3: Thundering herd prevention

  • Read

    Day 4: Feed caching (push vs pull)

  • Read

    Day 5: Multi-tier caching architecture

Next Week: Week 5 — Consistency and Coordination. We'll explore distributed transactions, consensus algorithms, and how to maintain consistency across services.

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 05 · Multi-Tier Caching
Finish Day 5Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~8 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

MetaPaper · ~40 min

An Analysis of Facebook Photo Caching

Qi Huang, Ken Birman et al. · 2013
What
A measurement study of every cache layer a Facebook photo passes through.
Key idea
Browser caches, edge caches and an origin cache each absorb part of the traffic before Haystack storage; smarter eviction such as S4LRU raises hit ratios.
Why here
This lesson's caching hierarchy measured end to end: what each tier catches and what is left for the next.
taught here: Caching hierarchy
MozillaDocs · ~23 min

HTTP caching

What
MDN's guide to how browsers and shared caches store HTTP responses.
Key idea
Cache-Control decides freshness, ETag and Last-Modified allow revalidation, and private vs shared caches decide who may store a response.
Why here
The browser and CDN tiers of this lesson are driven by these headers; getting them right is most of multi-tier invalidation.
taught here: Browser caching
CloudflareDocs · ~8 min

Tiered Cache

What
Cloudflare's documentation of tiered caching between its data centres.
Key idea
Lower-tier data centres ask an upper tier before going to the origin, so most misses are served inside the CDN and origin load drops.
Why here
Shows that the CDN in this lesson's hierarchy is itself multi-tier, with its own hit-ratio trade-offs.
taught here: CDN caching

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…