CDN vs Reverse Proxy vs Application Cache
CDNs, reverse proxies, and application caches all reduce repeated work, but they operate at different locations and understand different parts of a request. A CDN serves users from geographically distributed edge locations, a reverse proxy protects and accelerates origin infrastructure, and an application cache stores domain-specific data close to business logic.
These layers are complementary rather than interchangeable. A production architecture may cache the same response at the CDN, reverse proxy, and application layers, but every additional copy increases invalidation, privacy, observability, and stale-data complexity.
Table of Contents
- Where Each Cache Layer Operates
- CDN Cache
- Reverse Proxy Cache
- Application Cache
- CDN vs Reverse Proxy vs Application Cache Comparison
- Production Design Example
- Common Mistakes
- Production Checklist
- Conclusion
Where Each Cache Layer Operates
The main difference between these cache types is their position in the request path. Placement determines which latency, network, application, and database work can be avoided.
User
|
v
CDN edge cache
|
v
Load balancer or reverse proxy cache
|
v
Application service
|
+---- local application cache
|
+---- distributed Redis cache
|
v
Database
A response served by the CDN avoids the longest path. It does not reach the origin network, reverse proxy, application, Redis, or database. A reverse-proxy hit still reaches the origin environment but does not execute application code. An application-cache hit executes application routing and authorization logic but avoids an expensive database or external-service operation.
| Layer | Typical Location | Understands | Avoids |
|---|---|---|---|
| CDN | Geographically distributed edge | HTTP requests, headers, paths, cookies, and query parameters | Origin network and all backend processing |
| Reverse proxy | Origin network in front of services | HTTP routing, upstream health, headers, and response status | Application execution and downstream dependencies |
| Application cache | Inside or beside the application | Users, tenants, permissions, entities, and business rules | Database queries, computation, and external API calls |
The highest cache layer capable of safely serving a request usually provides the largest performance benefit. The application layer remains necessary for data that requires domain-aware keys, authorization, or partial caching.
CDN Cache
A content delivery network stores responses at edge locations close to users. It is designed to reduce geographic latency, origin bandwidth, and backend request volume.
CDNs are strongest when many users request identical public content. They can also cache APIs, but authenticated and personalized responses require careful key design and privacy controls.
Request Flow
First request from Europe
User
|
v
European edge: MISS
|
v
Origin reverse proxy
|
v
Application
|
v
Database
|
v
Response stored at edge
Later requests
User
|
v
European edge: HIT
|
v
Response returned without origin request
The first request pays the complete origin cost. Later requests can be served from the edge until the response expires, is evicted, or is invalidated.
Common CDN cache candidates include:
- images, videos, fonts, CSS, and JavaScript
- downloadable documents
- public articles and documentation
- public product catalog pages
- anonymous GET API responses
- generated thumbnails and transformed media
Configuration Example
The origin controls shared caching through HTTP response headers:
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from fastapi import FastAPI, Response
app = FastAPI()
@dataclass(frozen=True)
class PublicCarrier:
carrier_code: str
display_name: str
updated_at: datetime
@app.get("/public/carriers")
async def list_public_carriers(
response: Response,
) -> dict[str, list[dict[str, object]]]:
carriers = [
PublicCarrier(
carrier_code="FX",
display_name="Example Express",
updated_at=datetime(
2026,
8,
1,
tzinfo=timezone.utc,
),
),
PublicCarrier(
carrier_code="UP",
display_name="Example Parcel",
updated_at=datetime(
2026,
8,
1,
tzinfo=timezone.utc,
),
),
]
# Browsers may reuse for 60 seconds.
# Shared caches may reuse for 15 minutes.
# Stale content may be served briefly while revalidation occurs.
response.headers["Cache-Control"] = (
"public, "
"max-age=60, "
"s-maxage=900, "
"stale-while-revalidate=60, "
"stale-if-error=300"
)
response.headers["ETag"] = '"carriers-v42"'
return {
"carriers": [
asdict(carrier)
for carrier in carriers
]
}
max-age controls browser caching, while s-maxage targets shared caches such as CDNs. stale-while-revalidate allows a stale response to be returned while the CDN refreshes it in the background. stale-if-error can preserve availability during origin failures.
Static assets should use content-addressed names:
/assets/app.4f7c91a2.js
/assets/styles.97bd210c.css
/images/logo.2cc804f1.svg
When the content changes, the filename changes. Old assets can therefore receive long cache lifetimes without explicit invalidation.
from fastapi import Response
def apply_versioned_asset_headers(
response: Response,
) -> None:
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable"
)
Advantages, Disadvantages, and Use Cases
Advantages:
- Reduces geographic network latency.
- Removes traffic before it reaches origin infrastructure.
- Reduces bandwidth and application cost.
- Absorbs high traffic for popular public content.
- Can serve stale responses during origin failures.
- Protects static origins from repeated requests.
Disadvantages:
- Invalidation is distributed across many edge locations.
- Incorrect cache keys can expose personalized data.
- Debugging differs by location and edge state.
- Cold edges still reach the origin.
- Low-traffic content may be evicted before reuse.
- Highly dynamic responses often have low hit ratios.
When to use:
- static assets and media
- public websites and documentation
- public read-heavy APIs
- globally distributed users
- large files and bandwidth-heavy content
- responses that can tolerate controlled staleness
When not to use: private responses unless the cache key and response headers explicitly isolate every relevant user or authorization dimension.
Reverse Proxy Cache
A reverse proxy sits inside the origin environment in front of application servers. Examples include NGINX, Varnish, Envoy, and caching API gateways.
Unlike a CDN, a reverse proxy is not primarily intended to reduce global network distance. It protects the application tier by serving repeated HTTP responses before the request reaches application code.
Request Flow
Client or CDN
|
v
Reverse proxy
|
+---- cache hit ----> Return response
|
+---- cache miss
|
v
Application service
|
v
Database
A reverse proxy can combine caching with:
- TLS termination
- routing
- load balancing
- compression
- rate limiting
- request buffering
- health checking
- upstream retries
It is useful when responses should be shared across application instances but do not need global edge distribution.
NGINX Cache Example
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:100m
max_size=10g
inactive=60m
use_temp_path=off;
map $request_method $skip_method_cache {
default 1;
GET 0;
HEAD 0;
}
map $http_authorization $skip_authenticated_cache {
default 1;
"" 0;
}
server {
listen 443 ssl;
server_name api.example.com;
location /public/ {
proxy_pass http://application_upstream;
proxy_cache api_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 10m;
proxy_cache_valid 404 15s;
proxy_cache_valid any 0;
proxy_cache_bypass
$skip_method_cache
$skip_authenticated_cache;
proxy_no_cache
$skip_method_cache
$skip_authenticated_cache;
proxy_cache_lock on;
proxy_cache_lock_timeout 3s;
proxy_cache_use_stale
error
timeout
updating
http_500
http_502
http_503
http_504;
add_header X-Proxy-Cache $upstream_cache_status always;
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
proxy_cache_lock allows one request to refresh an expired key while other requests wait or use stale content, reducing origin stampedes. Authenticated requests bypass this public cache because authorization is not part of the cache key.
Reverse proxies can also honor application-generated caching headers rather than defining every TTL in proxy configuration. Central headers usually make business-specific freshness easier to manage.
Advantages, Disadvantages, and Use Cases
Advantages:
- Removes repeated requests before application execution.
- Shares cached responses across application instances.
- Supports stale responses during upstream failures.
- Provides request coalescing at the HTTP layer.
- Can be introduced without modifying every application endpoint.
- Combines caching with routing and traffic protection.
Disadvantages:
- Understands HTTP but not complete business semantics.
- Complex authorization logic is difficult to encode safely.
- Cache configuration can become disconnected from application changes.
- Local disk caches may differ across proxy instances.
- Invalidation across multiple proxies requires coordination.
- Incorrect query-string or header handling can return wrong responses.
When to use:
- public or shared API responses
- server-rendered public pages
- expensive anonymous GET endpoints
- origin shielding behind a CDN
- temporary stale serving during application outages
- services where application execution is expensive
When not to use: responses whose correctness depends on domain state that cannot be represented safely through HTTP cache keys and headers.
Application Cache
An application cache is controlled by business logic. It can store complete entities, query results, authorization metadata, computed values, external API responses, or intermediate objects.
The cache may be local to one process or distributed through Redis or Memcached. Unlike CDN and reverse-proxy caches, the application can understand tenant ownership, entity versions, permissions, and consistency requirements.
Request Flow
Request reaches application
|
v
Authentication and authorization
|
v
Build domain-specific cache key
|
+---- hit ----> Apply business logic and return
|
+---- miss
|
v
Database or external API
|
v
Store domain result
|
v
Return
The application still pays routing, authentication, and execution cost, but avoids expensive downstream work.
Application caches are appropriate when keys require domain information such as:
- account ID
- user ID
- permission version
- entity version
- locale
- feature configuration
- business state
Python and Redis Example
The following cache-aside service stores account-scoped shipment summaries. PostgreSQL remains authoritative.
from __future__ import annotations
import asyncio
import json
import logging
import random
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Protocol
from redis.asyncio import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ShipmentSummary:
shipment_id: str
account_id: int
status: str
carrier_code: str | None
tracking_number: str | None
updated_at: datetime
version: int
class ShipmentRepository(Protocol):
async def get_summary(
self,
account_id: int,
shipment_id: str,
) -> ShipmentSummary | None:
...
class ShipmentApplicationCache:
def __init__(
self,
redis: Redis,
repository: ShipmentRepository,
active_ttl_seconds: int = 60,
completed_ttl_seconds: int = 1_800,
) -> None:
self._redis = redis
self._repository = repository
self._active_ttl_seconds = active_ttl_seconds
self._completed_ttl_seconds = completed_ttl_seconds
self._locks: dict[str, asyncio.Lock] = {}
@staticmethod
def _key(
account_id: int,
shipment_id: str,
) -> str:
return (
f"shipment-summary:v1:"
f"account:{account_id}:"
f"shipment:{shipment_id}"
)
@staticmethod
def _ttl_with_jitter(ttl_seconds: int) -> int:
jitter = max(1, int(ttl_seconds * 0.15))
return ttl_seconds + random.randint(-jitter, jitter)
async def get(
self,
account_id: int,
shipment_id: str,
) -> ShipmentSummary | None:
key = self._key(account_id, shipment_id)
cached = await self._get_cached(key)
if cached is not None:
return cached
lock = self._locks.setdefault(key, asyncio.Lock())
async with lock:
cached = await self._get_cached(key)
if cached is not None:
return cached
shipment = await self._repository.get_summary(
account_id=account_id,
shipment_id=shipment_id,
)
if shipment is None:
return None
ttl_seconds = (
self._completed_ttl_seconds
if shipment.status in {"delivered", "cancelled"}
else self._active_ttl_seconds
)
try:
await self._redis.set(
key,
json.dumps(
asdict(shipment),
default=str,
),
ex=self._ttl_with_jitter(ttl_seconds),
)
except RedisError:
logger.exception(
"Failed to populate shipment cache",
extra={"cache_key": key},
)
return shipment
async def invalidate(
self,
account_id: int,
shipment_id: str,
) -> None:
key = self._key(account_id, shipment_id)
try:
await self._redis.delete(key)
except RedisError:
logger.exception(
"Failed to invalidate shipment cache",
extra={"cache_key": key},
)
raise
async def _get_cached(
self,
key: str,
) -> ShipmentSummary | None:
try:
payload = await self._redis.get(key)
except RedisError:
logger.exception(
"Shipment cache lookup failed",
extra={"cache_key": key},
)
return None
if payload is None:
return None
decoded = json.loads(payload)
return ShipmentSummary(
shipment_id=str(decoded["shipment_id"]),
account_id=int(decoded["account_id"]),
status=str(decoded["status"]),
carrier_code=decoded["carrier_code"],
tracking_number=decoded["tracking_number"],
updated_at=datetime.fromisoformat(
decoded["updated_at"]
),
version=int(decoded["version"]),
)
The key includes the account ID to prevent cross-tenant collisions. Completed shipments receive longer TTLs because they change less frequently. Local request coalescing prevents simultaneous cache misses inside one application instance.
Invalidation should follow a committed database transaction:
BEGIN;
UPDATE shipments
SET status = :new_status,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE account_id = :account_id
AND shipment_id = :shipment_id
AND version = :expected_version
RETURNING version;
INSERT INTO outbox_events (
event_id,
aggregate_type,
aggregate_id,
aggregate_version,
event_type,
payload
)
VALUES (
:event_id,
'shipment',
:shipment_id,
:new_version,
'shipment.updated',
jsonb_build_object(
'account_id', :account_id,
'shipment_id', :shipment_id,
'version', :new_version
)
);
COMMIT;
An outbox consumer deletes the Redis key after commit. The next read reconstructs the current representation.
Advantages, Disadvantages, and Use Cases
Advantages:
- Understands tenants, permissions, entities, and business state.
- Can cache partial computations instead of complete HTTP responses.
- Supports domain-specific TTL and invalidation policies.
- Can cache database and external-service results.
- Provides precise keys for personalized content.
- Can bypass the cache for consistency-sensitive workflows.
Disadvantages:
- Requests still execute application code.
- Cache logic increases application complexity.
- Serialization and version compatibility must be managed.
- Distributed cache failures can redirect load to databases.
- Local caches can diverge across instances.
- Business-specific invalidation is difficult to test completely.
When to use:
- tenant-specific entity data
- authorization and feature metadata
- database query results
- computed pricing or routing results
- external API responses
- data requiring business-aware keys and TTLs
When not to use: public static content that can be served earlier and more cheaply by a browser, CDN, or reverse proxy.
CDN vs Reverse Proxy vs Application Cache Comparison
These cache layers solve different bottlenecks. A CDN reduces distance and origin traffic, a reverse proxy reduces application traffic, and an application cache reduces database or computation traffic.
| Property | CDN | Reverse Proxy | Application Cache |
|---|---|---|---|
| Location | Global edge network | Origin network | Application process or shared cache service |
| Primary goal | Reduce geographic latency and origin traffic | Protect application servers | Reduce database and computation work |
| Cache unit | HTTP response or asset | HTTP response | Entity, query, computation, or domain object |
| Business awareness | Low | Low to Medium | High |
| Personalization safety | Requires strict key isolation | Requires strict key isolation | Can use domain-aware keys and authorization |
| Origin request on hit | No | Yes, but application is skipped | Yes, application executes |
| Database request on hit | No | No | No |
| Invalidation scope | Distributed edge locations | Proxy fleet | Domain keys or cache cluster |
| Typical cost benefit | Bandwidth and origin infrastructure | Application compute | Database, API, and computation capacity |
| Best use | Public globally reused responses | Shared origin HTTP responses | Dynamic and personalized domain data |
Cache Keys and Personalized Content
A cache is safe only when its key contains every request dimension that can change the response.
Possible key dimensions include:
- host and path
- relevant query parameters
- locale
- content encoding
- tenant ID
- user or role
- authorization scope
- feature version
- response representation version
Adding every header and cookie to a CDN or reverse-proxy key is not a good default. It creates many unique variants and destroys the hit ratio. Personalized responses are often better marked private and cached at the application layer.
from fastapi import Response
def apply_private_response_headers(
response: Response,
) -> None:
response.headers["Cache-Control"] = (
"private, no-store"
)
response.headers["Vary"] = "Authorization"
Vary tells HTTP caches which request headers affect the representation, but varying on authorization or high-cardinality cookies can create operational and privacy risks. Private responses should usually bypass shared caches completely.
Freshness and Invalidation
Expiration and invalidation become harder as data moves farther from the authoritative database.
Database update
|
+--> application cache invalidation
|
+--> reverse proxy purge
|
+--> CDN invalidation
|
+--> browser may still retain response
Each layer may observe the update at a different time. The system should define a maximum stale window rather than assume immediate global invalidation.
| Technique | Benefit | Trade-Off |
|---|---|---|
| Short TTL | Bounds stale duration | Increases refresh traffic |
| Explicit purge | Faster removal | Distributed operation can fail or lag |
| Versioned URL or key | Avoids stale collisions | Requires version propagation |
| Conditional revalidation | Avoids transferring unchanged responses | Still reaches the origin |
| Stale-while-revalidate | Maintains low latency during refresh | Temporarily serves stale content |
Immutable static assets should use versioned URLs. Dynamic public data often works well with short shared TTLs and background revalidation. Personalized data should use application-level invalidation and authoritative fallback.
Production Design Example
Consider a global logistics platform serving static frontend assets, public carrier information, public tracking pages, authenticated shipment details, and internal pricing calculations.
These workloads require different cache layers:
- Static assets should be cached globally for a long time.
- Public carrier data can tolerate several minutes of staleness.
- Public tracking pages need origin protection and controlled refresh.
- Authenticated shipment details must remain tenant-isolated.
- Pricing calculations require business-aware application keys.
Multi-Level Architecture
Global users
|
v
CDN
|
+---- immutable static assets
+---- public carrier API
+---- public tracking pages
|
v
Reverse proxy
|
+---- origin response cache
+---- stale response during upstream failure
+---- request coalescing
|
v
FastAPI services
|
+---- local configuration cache
+---- Redis shipment and pricing cache
|
v
PostgreSQL and external provider APIs
The highest safe layer handles each request:
- Static assets: browser and CDN only.
- Public carrier list: CDN with shared TTL and revalidation.
- Public tracking page: CDN plus reverse-proxy origin shielding.
- Authenticated shipment details: shared HTTP caches bypassed; Redis application cache used after authorization.
- Pricing calculation: application cache keyed by account, route, service, weight, and pricing version.
Read, Write, and Invalidation Flows
Public carrier-list read:
- The user requests the carrier API through the CDN.
- An edge hit returns immediately.
- An edge miss reaches the reverse proxy.
- A proxy hit shields the application from CDN cold misses.
- If both caches miss, the application loads data and returns cache headers.
Authenticated shipment read:
- The CDN and reverse proxy bypass shared caching because authorization is present.
- The application validates account ownership.
- A tenant-scoped Redis key is checked.
- On a miss, PostgreSQL is queried and Redis is populated.
Shipment update:
- PostgreSQL commits the new state and an outbox event.
- An application-cache consumer deletes the Redis key.
- A public-page invalidation consumer purges or versions the public tracking representation.
- Short edge TTLs bound stale content if invalidation is delayed.
Static deployment:
- The build generates content-hashed asset filenames.
- Assets are uploaded before the new HTML references them.
- Assets receive one-year immutable caching.
- The HTML document receives a short TTL or no-cache revalidation.
- No global asset purge is required because filenames changed.
Failure Scenarios
The CDN is unavailable. DNS or routing may fail over to the origin path where supported. The reverse proxy and application must have enough limited capacity for degraded traffic, but should not assume they can absorb the entire normal CDN load indefinitely.
The origin application fails. The CDN and reverse proxy may serve stale public responses. Authenticated dynamic operations fail or return controlled degradation because stale private data may not be safe.
Redis fails. Application reads fall back to PostgreSQL with bounded connection pools, rate limits, and request coalescing. Public CDN and proxy hits continue protecting part of the workload.
A cache key omits locale. Users receive content in the wrong language. Cache-key tests must verify every response-changing dimension.
An authenticated response is accidentally marked public. Shared caches can expose data across users. Security tests should fail any private endpoint returning public shared-cache headers.
CDN invalidation is delayed. Versioned URLs avoid the problem for static assets. Dynamic public responses rely on bounded TTLs and revalidation.
Many edge locations become cold after deployment. The reverse proxy shields the application by consolidating repeated origin requests.
The reverse-proxy cache is lost after restart. CDN hits still absorb public traffic. Origin request coalescing and application caches reduce the cold-start impact.
Monitoring
Each layer requires separate metrics because a high hit ratio at one layer can hide failures or inefficiency elsewhere.
| Layer | Critical Metrics | Primary Risk |
|---|---|---|
| CDN | Edge hit ratio, origin requests, bandwidth, purge status, edge latency | Unexpected origin traffic or unsafe shared caching |
| Reverse proxy | Hit status, upstream latency, stale responses, cache-lock waits | Application overload after proxy misses |
| Application cache | Hit ratio, Redis latency, evictions, invalidation failures, fallback reads | Database saturation during cache failure |
Track end-to-end request outcomes using response headers and tracing:
CDN-Cache-Status: HIT
X-Proxy-Cache: MISS
X-Application-Cache: HIT
X-Request-ID: 1f3be76c...
Not every header must be exposed publicly, but equivalent fields should exist in logs and traces. Useful alerts include:
- sudden CDN hit-ratio decline
- unexpected origin bandwidth growth
- reverse-proxy miss spikes
- increased application-cache fallback traffic
- Redis evictions or command latency
- purge and invalidation failures
- private responses entering shared caches
- stale response age exceeding business limits
Common Mistakes
| Mistake | Production Impact | Better Approach |
|---|---|---|
| Treating CDN, reverse proxy, and Redis as interchangeable | The chosen layer cannot understand or remove the intended work | Select the highest safe layer for each response |
| Caching authenticated responses publicly | Private data can leak between users | Use private or no-store headers and domain-aware application caching |
| Ignoring query parameters in cache keys | Different requests receive the same response | Include only response-changing parameters in a normalized key |
| Including every cookie and header in the key | Cache variants explode and the hit ratio collapses | Cache shared content separately from personalized content |
| Using long TTLs for mutable HTML | Deployments and content changes remain stale | Use short TTLs, revalidation, or versioned URLs |
| Using short TTLs for immutable assets | Browsers and edges repeatedly revalidate unchanged files | Use content hashes and long immutable caching |
| Invalidating before the database commits | Concurrent readers can repopulate old state | Publish invalidation after commit through an outbox |
| Relying only on explicit purge | Failed purges leave stale content indefinitely | Keep finite TTLs as a recovery boundary |
| Running a CDN without origin shielding | Cold edges independently overload the application | Use a reverse-proxy or centralized shield cache |
| Caching full HTTP responses when only one query is expensive | Personalization and invalidation become unnecessarily difficult | Cache the expensive domain result inside the application |
| Monitoring only the application cache | Edge misses and proxy inefficiency remain hidden | Measure every cache layer and the work passed downstream |
| Assuming stale serving is always safe | Users receive outdated security, price, or workflow data | Enable stale responses only for explicitly tolerant endpoints |
Production Checklist
- Choose the highest safe cache layer for each response.
- Mark private and authenticated responses as non-shareable.
- Include every response-changing dimension in cache keys.
- Avoid high-cardinality cookies and headers in shared-cache keys.
- Use content-hashed filenames for immutable assets.
- Define separate browser and shared-cache TTLs.
- Use request coalescing for popular expired responses.
- Keep finite TTLs even with explicit invalidation.
- Publish invalidations only after authoritative writes commit.
- Use origin shielding to protect applications from cold edges.
- Protect databases during application-cache failures.
- Test cache keys for tenant, locale, authorization, and query isolation.
- Monitor hits, misses, stale serves, purges, evictions, and fallback traffic.
- Load-test cold-cache deployment and complete cache-layer outages.
- Document maximum acceptable staleness for every cached endpoint.
Conclusion
A CDN is best for globally shared public content, a reverse proxy protects origin applications from repeated HTTP work, and an application cache handles domain-specific and personalized data. Production systems often use all three, but each additional layer increases key, freshness, invalidation, and observability complexity.
Key Takeaway: Cache responses at the earliest layer that can serve them safely: use CDNs for globally reusable public content, reverse proxies for shared origin responses, and application caches for tenant-aware, personalized, or business-specific data.
More Articles to Read
- Caching Explained: Improving Performance Without Overloading Databases
- Cache in Software System Design — A Practical Guide
- Understanding Caching in Scalable Systems
- Cache-Aside vs Write-Through vs Write-Behind
- Designing Multi-Level Caching Architectures
- Cache Invalidation Strategies for Production Systems
- Preventing Cache Stampedes and Hot Keys
- Caching Best Practices for Distributed Applications
Comments (0)