CDN Edge Caching: How It Works and How to Use It Effectively

A CDN (Content Delivery Network) caches content at edge nodes geographically close to users, reducing latency and offloading traffic from origin servers. Used correctly, a CDN can serve 90%+ of traffi

Introduction#

A CDN (Content Delivery Network) caches content at edge nodes geographically close to users, reducing latency and offloading traffic from origin servers. Used correctly, a CDN can serve 90%+ of traffic without hitting your servers. Used incorrectly, it adds complexity and cost without benefit.

How Edge Caching Works#

1
2
3
4
5
6
7
8
9
10
11
User in Tokyo               CDN Edge (Tokyo)              Origin (us-east-1)
     |                           |                              |
     |--GET /api/products------->|                              |
     |                           |-- Cache MISS                |
     |                           |--GET /api/products--------->|
     |                           |<--200 products + headers----|
     |<--200 products------------|-- Cached for TTL            |
     |                           |                              |
     |--GET /api/products------->|                              |
     |<--200 products (HIT)------|-- Served from edge          |
     |   (from Tokyo, ~2ms)      |   (no origin hit)           |

Cache-Control Headers#

The CDN respects Cache-Control headers from your origin to decide what to cache and for how long.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# FastAPI: setting cache headers
from fastapi import Response

@app.get("/api/products")
async def get_products(response: Response):
    response.headers["Cache-Control"] = "public, max-age=300, s-maxage=3600"
    # public: CDN may cache this
    # max-age=300: browser caches for 5 minutes
    # s-maxage=3600: CDN caches for 1 hour (overrides max-age for CDN)
    return products

@app.get("/api/user/profile")
async def get_profile(response: Response, user: User = Depends(get_current_user)):
    response.headers["Cache-Control"] = "private, max-age=60"
    # private: only the browser may cache — CDN must not cache
    return user.profile

@app.get("/api/prices")
async def get_prices(response: Response):
    response.headers["Cache-Control"] = "no-store"
    # no-store: nothing may cache — always fresh from origin
    return current_prices
Directive Meaning
public CDN and browser may cache
private Only browser may cache
no-cache Must revalidate with origin before using
no-store Nothing may cache
max-age=N Browser TTL in seconds
s-maxage=N CDN TTL in seconds (overrides max-age for CDN)
stale-while-revalidate=N Serve stale while refreshing in background

Cache Keys#

The cache key determines what constitutes a “unique” request. By default, CDNs key on URL + Host. Add headers or query parameters with care.

1
2
3
4
5
6
7
8
9
# Cloudflare: cache key configuration (in Page Rules or Cache Rules)
Cache-Key: ${host}${uri}  # default

# Problem: if your origin varies response by Accept-Language,
# you need to include it in the cache key
# Otherwise German users get English cached content

# Vary header: tells CDN to include these headers in the cache key
Vary: Accept-Language, Accept-Encoding
1
2
3
4
5
6
7
8
9
10
@app.get("/api/products")
async def get_products(
    request: Request,
    response: Response,
    language: str = "en"
):
    response.headers["Cache-Control"] = "public, s-maxage=3600"
    response.headers["Vary"] = "Accept-Language"
    products = await get_localized_products(language)
    return products

Avoid Vary: Cookie — it effectively disables CDN caching for authenticated users.

Cache Invalidation#

TTL-based expiry works but means stale content for up to TTL seconds after updates. Purging allows immediate invalidation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Cloudflare: purge specific URLs after update
import httpx

CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"
CF_ZONE_ID = "your-zone-id"
CF_TOKEN = "your-api-token"

async def purge_urls(urls: list[str]) -> None:
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            f"{CLOUDFLARE_API}/zones/{CF_ZONE_ID}/purge_cache",
            headers={"Authorization": f"Bearer {CF_TOKEN}"},
            json={"files": urls}
        )
        resp.raise_for_status()

# After updating products, purge related CDN entries
async def update_product(product_id: int, data: dict):
    await db.update_product(product_id, data)
    await purge_urls([
        f"https://example.com/api/products",
        f"https://example.com/api/products/{product_id}",
    ])

For mass invalidation, use cache tags (supported by Cloudflare Enterprise, Fastly):

1
2
3
4
5
6
7
8
9
10
11
12
13
# Tag responses with a cache tag
@app.get("/api/products")
async def get_products(response: Response):
    response.headers["Cache-Tag"] = "products"  # Cloudflare/Fastly
    response.headers["Surrogate-Key"] = "products"  # Fastly
    return products

# Purge everything tagged "products"
async def purge_by_tag(tag: str):
    await client.post(
        f"{CF_API}/zones/{ZONE}/purge_cache",
        json={"tags": [tag]}
    )

What to Cache vs What Not To#

Cache (CDN-appropriate):

  • Product catalogs, public listings
  • Static API responses that don’t vary by user
  • Images, CSS, JS, fonts (long TTLs)
  • Documentation pages

Do not cache:

  • User-specific data (cart, profile, recommendations)
  • Authentication endpoints
  • Write operations (POST, PUT, DELETE)
  • Real-time data (stock prices, live scores)
  • Responses with sensitive data

Cache Bypass for Authenticated Requests#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.get("/api/products")
async def get_products(
    request: Request,
    response: Response,
    authorization: str | None = Header(None),
):
    if authorization:
        # Authenticated: return personalized, non-cacheable response
        response.headers["Cache-Control"] = "private, no-store"
        return await get_personalized_products(authorization)
    else:
        # Anonymous: cacheable
        response.headers["Cache-Control"] = "public, s-maxage=300"
        return await get_public_products()

Monitoring CDN Performance#

1
2
3
4
5
6
7
8
9
10
11
12
# Cache hit rate (should be > 80% for static content)
# Cloudflare: Analytics → Caching

# Response headers tell you if CDN served the response
curl -I https://example.com/api/products
# CF-Cache-Status: HIT    ← served from Cloudflare edge
# CF-Cache-Status: MISS   ← fetched from origin
# CF-Cache-Status: EXPIRED← served stale, revalidating
# CF-Cache-Status: BYPASS ← caching bypassed (no-store, POST, etc.)

# X-Cache header (older CDNs)
# X-Cache: HIT from edge.node.fastly.net

Conclusion#

CDN caching reduces latency and origin load for public content. Set s-maxage for CDN TTL and max-age for browser TTL separately. Use Vary headers only when necessary — they fragment the cache. Implement purge-on-update for content that must not remain stale. Never cache user-specific or sensitive responses — use private, no-store for these.

Contents