Which API responses can a CDN safely cache?
CDN caching for API responses means letting an edge cache store a response and serve it to later requests without contacting your origin. Every cache hit removes one request from your application servers and database. It also shortens the client's round trip, because the response comes from a nearby edge location. And it reduces egress, because the bytes leave your origin once and the CDN serves the repeats. Whether that egress saving shows up on your bill depends on how your provider prices origin-to-CDN transfer.
The real question is not whether to cache. It is which responses are safe to share between clients. Most APIs fall into four groups:
| Group | Example endpoint | CDN caching |
|---|---|---|
| Public, identical for everyone | GET /v1/countries, product catalogue |
Cache aggressively with a long TTL |
| Varies by a few parameters | GET /v1/products?category=shoes&locale=de-DE |
Cache, with those parameters in the cache key |
| Per-user | GET /v1/me/recommendations |
Usually skip at the CDN; cache in the client or app tier |
| Never cacheable | POST /v1/orders, account balances, permission checks, tokens, PII |
Never store at a shared cache |
Read-heavy public or semi-public APIs benefit most. Many clients request the same small set of resources, so a single origin response can serve thousands of hits.
Design cache keys, TTLs and invalidation before launch. If you add them after an outage, you are working under pressure. In that situation it is easy to key on too little and serve one user's data to another, or to set a TTL you cannot shorten quickly. Changing either one later can also mean coordinated changes across clients, the CDN configuration and the origin.
How do Cache-Control headers behave at a shared cache?
Directives split into two groups: those that decide who may store a response, and those that decide how long and under what conditions it can be reused. A shared cache (the CDN edge) and a private cache (the browser or an HTTP client library) read the same header but apply some directives differently.
publiclets shared caches store the response even when the request carried anAuthorizationheader, which they would otherwise refuse.privaterestricts storage to the end client. The edge must not keep it.no-storeforbids storage everywhere.no-cacheallows storage but requires revalidation with the origin before every reuse. It does not mean "don't cache."s-maxageoverridesmax-agefor shared caches only. This lets the edge hold an object for minutes while browsers keep it for seconds. You can then purge the edge copy, but you cannot purge copies already held by clients.stale-while-revalidateserves the expired copy while a background refresh runs, which hides origin latency.stale-if-errorserves the expired copy when the origin fails.
CDN support for the two stale directives varies, so confirm that yours honours them.
| Endpoint class | Example Cache-Control |
Edge | Browser |
|---|---|---|---|
| Public catalog | public, max-age=30, s-maxage=600, stale-while-revalidate=60, stale-if-error=86400 |
10 min | 30 s |
| Reference data | public, max-age=3600, s-maxage=86400 |
1 day | 1 h |
| Per-user profile | private, max-age=0, no-cache |
none | revalidate |
| Tokens, payments | no-store |
none | none |
The dangerous case is a personalized response that has no Cache-Control at all. Many CDNs apply a default TTL or heuristic caching to such responses. The first user's data can then be served to everyone who shares the same cache key. Set private or no-store explicitly on every authenticated route rather than relying on its absence.
// Set Cache-Control explicitly per endpoint class so the CDN never falls back
// to a default TTL or heuristic caching. Uses only Node's built-in http module.
const http = require('node:http');
const POLICIES = {
// Edge keeps it 10 min (s-maxage), browsers 30 s (max-age).
// Purging the edge fixes bad data quickly; client copies expire in 30 s.
catalog: 'public, max-age=30, s-maxage=600, stale-while-revalidate=60, stale-if-error=86400',
// Rarely changes: edge 1 day, browser 1 h.
reference: 'public, max-age=3600, s-maxage=86400',
// Personalized: the edge must not store it; the browser revalidates each time.
profile: 'private, max-age=0, no-cache',
// Secrets: nobody stores it, anywhere.
sensitive: 'no-store',
};
const ROUTES = {
'/v1/products': ['catalog', { items: [] }],
'/v1/countries': ['reference', { countries: ['DE', 'FR'] }],
'/v1/me': ['profile', { id: 'user-123', email: 'user@example.com' }],
'/v1/payments/token': ['sensitive', { token: 'tok_placeholder' }],
};
http.createServer((req, res) => {
const route = ROUTES[new URL(req.url, '').pathname];
if (!route) {
// Errors get an explicit policy too, so a 404 isn't cached by default.
res.writeHead(404, { 'Cache-Control': 'no-store' });
return res.end();
}
const [policy, body] = route;
res.setHeader('Cache-Control', POLICIES[policy]);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(body));
}).listen(8080);
A minimal Node.js origin that sets a Cache-Control policy on every response, including errors. Public endpoints get separate edge (s-maxage) and browser (max-age) TTLs, and personalized or sensitive responses get private or no-store so a shared cache never stores them.
How should you design cache keys and use Vary?
The cache key decides which requests share a stored response. Every component in it multiplies the number of variants the CDN must hold, so include only inputs that actually change the response body.
Query parameters. Allowlist the parameters your handler reads, such as page, limit, and sort, and drop everything else. If the CDN forwards the full query string, tracking tags, cache-busting timestamps, and random client nonces each create a distinct key. A popular endpoint then becomes thousands of single-hit entries, and the hit ratio falls toward zero while origin traffic looks the same as with no CDN at all. Normalize what remains: sort parameter order, lowercase case-insensitive values, and strip defaults so that ?limit=20 and no limit resolve to one entry.
Headers and Vary. Vary: Accept-Encoding is almost always correct, because gzip, Brotli, and identity bodies differ. Add Vary: Accept only if you genuinely negotiate formats. Avoid Vary: Authorization or Vary: Cookie. Each user's value is unique, so the cache stores one copy per user and almost never serves a hit. Worse, the response is still sitting in a shared cache when it should have been marked private.
Cache poisoning. The real danger is a header that changes the response but is left out of the key. Suppose your origin builds absolute URLs from X-Forwarded-Host. An attacker sends a request with that header set to their own domain, and the CDN stores the result under the normal key. Every subsequent user then receives links pointing at the attacker. Audit every header your origin reads. Each one must either go into the key or be stripped at the edge before forwarding.
# Nginx as a shared cache in front of an API origin (same rules apply at a CDN).
proxy_cache_path /var/cache/nginx/api keys_zone=api:10m max_size=1g;
upstream api_origin { server 10.0.0.12:8080; }
# Strip defaults so ?limit=20 and no limit share one entry.
map $arg_limit $norm_limit { "" 20; default $arg_limit; }
map $arg_page $norm_page { "" 1; default $arg_page; }
server {
listen 80;
location /v1/products {
proxy_cache api;
proxy_cache_valid 200 60s;
# Allowlisted params only, in a fixed order. Tracking tags, timestamps
# and nonces never reach the key, so they cannot fragment the cache.
proxy_cache_key "$uri|page=$norm_page|limit=$norm_limit|sort=$arg_sort";
# Forward the same normalized query, so origin sees exactly what is keyed.
proxy_pass
# Headers the origin might read but which are not in the key: strip them
# (an empty value means nginx does not send the header) to block poisoning.
proxy_set_header Host api.example.com;
proxy_set_header X-Forwarded-Host "";
proxy_set_header Cookie "";
# Never store or serve shared copies of authenticated requests;
# don't try to make them cacheable with Vary: Authorization.
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;
# Origin should send Vary: Accept-Encoding; nginx keeps one variant
# per encoding. Accept-Encoding is the only request header that varies.
}
}
An nginx caching proxy that builds the key from an allowlisted, default-normalized set of query parameters, strips unkeyed headers such as X-Forwarded-Host and Cookie before they reach the origin, and bypasses the cache for requests carrying Authorization.
How does a CDN cache revalidate and invalidate API responses?
When a cached entry passes its TTL, the CDN does not have to throw it away. If the stored response carried an ETag or Last-Modified header, the edge sends a conditional request with If-None-Match or If-Modified-Since. If nothing changed, the origin replies 304 Not Modified with headers and no body, and the edge marks its copy fresh again. This saves serialization work and response bytes, but the origin still has to compute the validator. Keep that step cheap by reading a stored version number or content hash. Building the full response just to hash it defeats the purpose.
Revalidation only handles expiry. You also need a way to make changed data visible.
| Strategy | How new data appears | Fits | Main cost |
|---|---|---|---|
Short TTL + stale-while-revalidate |
Entries age out and are refreshed in the background | Listings and feeds that tolerate seconds of staleness | Bounded staleness, steady revalidation traffic |
| Versioned or content-addressed URLs | New content gets a new URL, and old URLs stay cached for a long time | Immutable resources, exports, data addressed by ID and version | Clients must learn the new URL |
| Explicit purge | Keys are deleted on write | Data that must be correct immediately after a write | Slow propagation, rate limits, possible per-request fees |
Purges take time to reach every edge location, and providers cap how often you can send them. Order matters. If you purge before the write commits, or before the new deploy is serving traffic, the edge refetches old data and caches it again.
When a popular key expires, every concurrent miss can reach the origin at once. This is the thundering herd problem. Request collapsing sends one of those misses to the origin and makes the rest wait for its response. stale-while-revalidate avoids the wait entirely: the edge serves the old copy while a single background refresh runs.
const http = require('node:http');
// Stand-in for a database row. Every write bumps `version`.
const products = new Map([
['42', { version: 17, body: { id: '42', name: 'Widget', price: 1999 } }],
]);
// Fresh for 30s. For 60s after that, the edge may serve the stale copy while it revalidates in the background.
const CACHE_CONTROL = 'public, max-age=30, stale-while-revalidate=60';
http.createServer((req, res) => {
const match = req.url.match(/^\/api\/products\/(\w+)$/);
const row = match && products.get(match[1]);
if (!row) { res.writeHead(404); return res.end(); }
// Build the validator from the stored version, not by hashing a freshly serialized body.
// In a real service, read only the version column here.
const etag = `"p${match[1]}-v${row.version}"`;
// If-None-Match can hold a list of tags. Weak tags still match for GET.
const inm = req.headers['if-none-match'];
const tags = inm ? inm.split(',').map((t) => t.trim().replace(/^W\//, '')) : [];
if (tags.includes(etag) || tags.includes('*')) {
// Nothing changed: send headers only so the edge marks its copy fresh again.
res.writeHead(304, { ETag: etag, 'Cache-Control': CACHE_CONTROL });
return res.end();
}
// Changed or first fetch: pay for serialization and the full body once.
const payload = JSON.stringify(row.body);
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
ETag: etag,
'Cache-Control': CACHE_CONTROL,
});
res.end(payload);
}).listen(8080);
A minimal Node.js origin handler that derives its ETag from a stored version number, answers a matching If-None-Match with a body-less 304, and sets a short max-age with stale-while-revalidate so the CDN can refresh entries in the background.
How do you implement API caching with CloudFront cache policies?
CloudFront splits caching configuration into two objects. A cache policy defines the cache key (which headers, cookies and query strings distinguish one object from another) and the TTL bounds: a minimum, a maximum, and a default that applies when the origin sends no Cache-Control. An origin request policy lists extra values to forward to the origin without adding them to the key. Anything in the cache key is forwarded automatically. This split lets the origin receive context it needs for logging or routing without fragmenting the cache.
Step 1: Split routes into behaviors
Behaviors match path patterns in order, with * as the fallback. Give cacheable routes, such as /api/catalog/*, a policy whose key contains only the query parameters that change the response. Give personalized or mutating routes, such as /api/account/*, a policy with caching disabled. Make the fallback non-caching too, so a newly added endpoint is never cached by accident.
Step 2: Keep the cache key narrow
Every header or cookie you add to the key multiplies the number of variants. For values the origin needs but that don't change the response body, use the origin request policy instead.
Step 3: Let the origin set freshness
Keep the min and max TTLs wide and let Cache-Control decide freshness per response.
Client ──► Edge ──┬─ HIT (fresh) ─────────────► serve cached copy
├─ MISS ──► Origin ──200───► store, serve
└─ STALE ─► Origin (conditional request)
├─304──► refresh TTL, serve cached copy
└─200──► replace, serve
On revalidation, a 304 response carries no body. That saves egress even when the object has expired.
+--------+
| Client |
+---+----+
v GET /api/...
+------------------------------------------------------------+
| Edge: behaviors matched in order |
| /api/catalog/* -> cache policy (narrow key, TTL bounds) |
| /api/account/* -> caching disabled |
| * (fallback) -> caching disabled |
+-------------+------------------------------+---------------+
| cacheable | not cacheable
v |
+-----------------------------+ |
| Lookup by cache key | |
+--+-----------+----------+---+ |
| | | |
HIT MISS STALE |
(fresh) | (conditional req) |
| v v v
| +-------------------------------------------------+
| | Origin: gets cache-key values plus any extras |
| | listed in the origin request policy |
| +-------+---------------+----------------+--------+
| 200: store 304: refresh TTL 200: no store
| | 200: replace |
v v v v
+------------------------------------------------------------+
| Response returned to client from the edge |
+------------------------------------------------------------+
Behaviors route each path to a caching or non-caching policy; cacheable requests hit, miss or revalidate by cache key, and the origin also receives extra values from the origin request policy.
How much origin load and egress cost can caching save?
Illustrative estimate (assumed numbers, not a benchmark)
Assume an API serves R = 100 million requests/month, average response S = 20 KB (about 2,000 GB total), and origin internet egress at an assumed P = $0.08/GB. With cache hit ratio H:
- Origin requests = R × (1 − H)
- Origin egress (GB) = R × S × (1 − H)
- Gross egress avoided ($) = R × S × H × P
| Hit ratio (H) | Origin requests/month | Origin egress | Gross egress avoided |
|---|---|---|---|
| 90% | 10M | 200 GB | $144 |
| 60% | 40M | 800 GB | $96 |
| 30% | 70M | 1,400 GB | $48 |
Origin load scales with (1 − H), so savings are not linear. Moving from 90% to 95% halves origin traffic, while moving from 30% to 35% barely changes it. At low hit ratios you pay for an extra layer without much relief.
What offsets the savings
- CDN fees. The CDN still delivers all 2,000 GB and charges per request. If its transfer rate is close to your origin egress rate, the net dollar saving can be small or even negative.
- Origin-to-edge transfer. Depending on the provider and setup, misses and revalidations may still be billed.
- Low hit ratios. These often come from high-cardinality cache keys or short TTLs.
The larger gain is usually capacity. A 90% hit ratio means provisioning the origin for about a tenth of peak read traffic.
Effect on development cost
The engineering cost comes up front. You need to classify endpoints, design cache keys, and plan invalidation. In exchange, the team spends less time scaling databases and app tiers, and has fewer incidents caused by traffic spikes against uncached endpoints.
How do you measure results, and when should you avoid CDN caching?
Measuring hit ratio and origin load
A global hit ratio hides problems, so track it per endpoint or path pattern. One catalog route at 95% can mask a search route at 5% whose cache key includes a query parameter that is unique on every request. Put origin request rate next to it. The goal is fewer requests reaching your servers, and a small change to a cache key can drop the hit ratio without anyone noticing until origin load rises.
Before and after each change, run curl -sI against the endpoint twice. Check the cache status header (on CloudFront, X-Cache returns values such as Hit from cloudfront or Miss from cloudfront), the Age header, and Cache-Control. If a response is a hit but Age never increases, an earlier layer may be serving it.
Debugging gets harder once a CDN is involved. Before you can fix a stale or wrong response, you have to work out whether it came from the browser, the edge, a gateway, or the origin. Some bugs, such as a missing Vary dimension, only show up when real users with different headers hit the same object.
When to skip edge caching
Avoid edge caching for these cases:
- Strict freshness needs, such as balances or inventory at checkout.
- Highly personalized responses, where the cache key would be close to one entry per user.
- Low-reuse traffic, where most objects are evicted before a second request arrives.
| Alternative | Best for | Trade-off |
|---|---|---|
| In-memory application cache | Per-user or computed data | Cache misses still use app-tier compute |
| Client HTTP caching | Repeat requests from one client | Cannot be invalidated remotely |
| Read replicas | Read-heavy database load | Does not reduce egress |
| API gateway response cache | Centralized TTL control | Runs in one region, not at the edge |
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to cdn caching api responses, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.