CDN Caching Strategy for Dynamic Websites

Published 8 min read
cdncachingdynamic websitesswrcache invalidation

Dynamic sites still benefit heavily from CDN caching when cacheability rules, cache keys, and invalidation flows are intentionally designed. The misconception that dynamic means uncacheable costs teams both performance and money. According to web performance research, CDN cache hit rates above 85% reduce origin load by 6x and cut p95 response times by 60-70% for end users. Even pages with personalized elements can leverage edge caching for the majority of their content.

---

Build a Cacheability Matrix

Not all content is equally cacheable. Before configuring a CDN, classify every response type in your application:

Content ClassTTL StrategySWRCache ScopeExample

Static assets (JS, CSS, images)1 yearN/A (immutable)Public`/assets/main.a1b2c3.js`
Marketing pages5-15 minutes1 hourPublic`/pricing`, `/features`
Public API responses30-120 seconds5 minutesPublic`/api/catalog/products`
Shared dynamic content10-60 seconds2 minutesPublic`/api/status`, `/api/plans`
Personalized contentNo shared cacheN/APrivate`/api/user/profile`
Authenticated mutationsNo cacheN/APrivate`POST /api/orders`

This matrix becomes your source of truth for cache-control header configuration. Every backend endpoint should map to exactly one content class.

Cache-Control Header Configuration

The `Cache-Control` header is the contract between your origin and the CDN. Misconfigured headers are the root cause of most caching problems.

<h1>FastAPI middleware for content-class-based cache headers</h1>

from fastapi import Request, Response

from functools import wraps

CACHE_POLICIES = {

"static": "public, max-age=31536000, immutable",

"marketing": "public, max-age=600, stale-while-revalidate=3600",

"public_api": "public, max-age=60, stale-while-revalidate=300, stale-if-error=86400",

"shared_dynamic": "public, max-age=30, stale-while-revalidate=120",

"private": "private, no-store",

"no_cache": "no-store, no-cache",

}

def cache_policy(policy_name: str):

def decorator(func):

@wraps(func)

async def wrapper(*args, **kwargs):

response = await func(*args, **kwargs)

response.headers["Cache-Control"] = CACHE_POLICIES[policy_name]

return response

return wrapper

return decorator

@app.get("/api/catalog/products")

@cache_policy("public_api")

async def list_products():

return await db.fetch_products()

The stale-while-revalidate Pattern

`stale-while-revalidate` (SWR) is the most important directive for dynamic content. It tells the CDN: serve stale content immediately while fetching a fresh copy in the background.

Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400

This means:

  • For the first 60 seconds: serve from cache directly
  • From 60 to 360 seconds: serve stale content, revalidate in background
  • If origin returns 5xx: serve stale content for up to 24 hours

The user always gets a fast response. Origin load stays low. Content freshness stays within acceptable bounds.

Cache Key Design

The cache key determines when two requests are considered the same. A poorly designed cache key either reduces hit rates (too many unique keys) or serves wrong content (too few unique keys).

Rules for Cache Key Design

  • Include only dimensions that actually change the response
  • Strip tracking and analytics query parameters
  • Normalize parameter order
  • Include locale/region only when output differs
  • <h1>Nginx: strip tracking parameters from cache key</h1>
    

    set $clean_uri $uri;

    <h1>Remove utm_*, fbclid, gclid, ref from query string</h1>

    if ($args ~* "^(.*)(?:utm_[a-z]+

    fbclidgclid
    ref)=[^&]*(.*)" ) {

    set $args $1$2;

    }

    <h1>Normalize to lowercase for cache key consistency</h1>

    proxy_cache_key "$scheme$host$clean_uri$is_args$args";

    Vary Header Usage

    <h1>Correct: only vary on dimensions that change output</h1>
    

    Vary: Accept-Encoding, Accept-Language

    <h1>Dangerous: Cookie in Vary header fragments cache</h1>

    <h1>Vary: Cookie # Avoid this - every unique cookie creates a cache miss</h1>

    Never include `Cookie` in the `Vary` header for public content. If content changes based on authentication state, use separate endpoints or edge-side includes instead.

    Purge and Invalidation Strategy

    The best cache invalidation is no invalidation. Design for TTL-based expiry wherever possible. When you must purge, use surgical invalidation rather than global flushes.

    Tag-Based Invalidation

    <h1>Add surrogate keys to responses for targeted purging</h1>
    

    @app.get("/api/products/{product_id}")

    async def get_product(product_id: int, response: Response):

    product = await db.fetch_product(product_id)

    response.headers["Surrogate-Key"] = f"product-{product_id} products catalog"

    response.headers["Cache-Control"] = "public, max-age=300"

    return product

    <h1>When product is updated, purge by tag</h1>

    async def on_product_update(product_id: int):

    await cdn_client.purge_tag(f"product-{product_id}")

    # Also purge listing pages

    await cdn_client.purge_tag("catalog")

    Automated Purge from Deploy Pipeline

    <h1>GitHub Actions: purge CDN cache on deploy</h1>
    

    steps:

    - name: Deploy to production

    run: ./deploy.sh

    - name: Purge CDN cache for updated paths

    run: |

    curl -X POST "$CDN_API/purge" \

    -H "Authorization: Bearer $CDN_TOKEN" \

    -d '{"tags": ["marketing-pages", "pricing"]}'

    - name: Warm critical paths

    run: |

    for url in / /pricing /features /docs; do

    curl -s -o /dev/null "https://example.com${url}"

    done

    Personalization Without Cache Busting

    Personalized content is the biggest challenge for CDN caching. Several patterns handle this without sacrificing cache hit rates:

    Pattern 1: Client-Side Personalization

    Serve a cached shell page from CDN, then hydrate personalized elements via API calls from the browser. The page shell gets a 90%+ cache hit rate, and personalization loads asynchronously.

    Pattern 2: Edge-Side Includes (ESI)

    Some CDNs support ESI, which lets you cache the page frame and inject personalized fragments at the edge:

    <!-- Cached page shell -->
    

    <div class="header">

    <esi:include src="/api/user/greeting" />

    </div>

    <div class="content">

    <!-- This section is fully cached -->

    <h1>Product Catalog</h1>

    ...

    </div>

    Pattern 3: Segmented Caching

    Cache a small number of response variants based on user segment (logged-in vs anonymous, region, plan tier) rather than individual identity:

    <h1>Segment users into a small number of cache groups</h1>
    

    def get_cache_segment(request: Request) -> str:

    if not request.user:

    return "anonymous"

    if request.user.plan == "enterprise":

    return "enterprise"

    return "authenticated"

    <h1>Set segment header for CDN to use as cache key</h1>

    response.headers["X-Cache-Segment"] = get_cache_segment(request)

    Review infrastructure tradeoffs on the Cloud Computing page, budget implications on Pricing, and rollout support via Contact.

    Monitoring CDN Cache Performance

    MetricHealthy RangeAction If Out of Range

    Overall cache hit ratio> 85%Audit cache-control headers
    Static asset hit ratio> 98%Check immutable + content hash
    Origin request rateFlat or decliningInvestigate cache misses
    Purge frequency< 50/dayReduce manual purges, use TTL
    Stale-serve rate< 5%Origin may be unreliable
    p95 TTFB (cache hit)< 30msCDN edge performance issue
    p95 TTFB (cache miss)< 500msOrigin performance issue

    Frequently Asked Questions

    Can I cache API responses at the CDN edge?

    Yes, for public API responses that do not vary by user identity. Product catalogs, pricing data, feature flags, and status endpoints are excellent candidates. Use short TTLs (30-120 seconds) with `stale-while-revalidate` to keep content fresh while maintaining high hit rates.

    How do I prevent caching sensitive user data?

    Set `Cache-Control: private, no-store` on all endpoints that return user-specific data. Never rely on the CDN to figure this out automatically. Audit your cache headers monthly, and use a test that verifies no authenticated endpoint returns a `public` cache-control directive.

    What is the difference between purge and invalidation?

    Purge removes content from the cache immediately. Invalidation marks content as stale, causing the CDN to revalidate on the next request. Invalidation is generally safer because it falls back to stale content if the origin is unhealthy. Use purge only when serving stale content is unacceptable (security patches, legal compliance).

    How do I handle A/B tests with CDN caching?

    Assign the A/B variant at the edge using a cookie or header, then include that variant identifier in the cache key. This gives you 2-3 cached variants per page instead of fragmenting the cache by individual user. Most CDN providers support this via edge workers or transform rules.

    Should I warm the cache after a deploy?

    Yes, for your highest-traffic pages. After purging the CDN cache during deployment, make requests to your top 20-50 URLs from multiple edge regions. This prevents a thundering herd of cache misses hitting your origin simultaneously when real users arrive.

    Final Takeaway

    Great CDN caching for dynamic applications is policy-driven and observable, not accidental. Build a cacheability matrix, configure cache-control headers explicitly, design cache keys carefully, and monitor hit rates continuously. The investment pays off in lower origin costs, faster user experience, and better resilience during traffic spikes.