Cache at the CDN
A CDN in front of your gateway serves the cheapest request your API will ever
handle: it never reaches Zuplo, never reaches your backend, and returns from a
POP near the caller. The hard part is telling the CDN which responses it may
hold, and telling it something different from what you tell the browser.
Cache-Control serves two audiences, and only one of them can be purged.
The gateway is the right place to make that call, because only the gateway sees
whether this response has an empty result set, a Set-Cookie, or an upstream
no-store. The CDN Cache Control policy puts the decision there, so making an
endpoint cacheable is an edit to config/policies.json and a deploy rather than
a rule per path in Akamai Property Manager or the Cloudflare dashboard.
Add the policy
Add the policy to the policies array in config/policies.json. This example
caches at the edge for 30 minutes and in the client for 60 seconds.
Code
This is an outbound policy, so attach it to the policies.outbound array of the
route in config/routes.oas.json:
Code
The "cdn-cache-control" string must match the name field in
config/policies.json. Attach the same policy to every route that shares a
cache profile, and define a second policy with a different name for routes
that need different TTLs or tags.
Choose your CDN
cdn is required and has no default. Each CDN reads a different header, so a
default would silently produce a well-formed response with no edge caching at
all for anyone running a different one — a curl that looks correct and a hit
ratio of zero.
cdn | Edge TTL header | Purge tag header | Tag separator |
|---|---|---|---|
akamai | Edge-Control | Edge-Cache-Tag | comma |
fastly | Surrogate-Control | Surrogate-Key | space |
cloudflare | Cloudflare-CDN-Cache-Control | Cache-Tag | comma |
cloudfront | Cache-Control: s-maxage | none | — |
generic | CDN-Cache-Control (RFC 9213) | none | — |
Use generic for a CDN that implements RFC 9213 targeted cache control but is
not named above.
Naming the vendor also lets the policy reject configuration that vendor cannot
honor instead of dropping it silently: tags with cloudfront is a build-time
error pointing you at path invalidation, because CloudFront has no purge by tag.
The strategy option controls how the edge TTL travels. The default,
targeted, uses the CDN's own header. s-maxage folds the edge TTL into
Cache-Control instead, for a property that honors origin Cache-Control but
has not enabled the targeted header. CloudFront defaults to s-maxage because
it reads no targeted header, and strategy: "targeted" there is a build-time
error rather than a silent no-op.
Either way, the policy removes any targeted header your backend already set that
the configured CDN reads and that the policy is not writing itself. A targeted
header outranks Cache-Control, so an upstream Surrogate-Control with a large
max-age would otherwise beat the TTL configured here.
Split the edge TTL from the client TTL
Take a catalog endpoint on a busy storefront. The product list changes a few times a day and every caller sees the same body, so it gets a 30-minute edge TTL and a 60-second client TTL — the configuration in Add the policy above. On Akamai that emits:
Code
At steady state the edge serves nearly all of the traffic: one request per POP per 30 minutes reaches Zuplo. Clients revalidate once a minute, which costs a conditional request to the edge, not to your backend.
Now merchandising updates a product and your catalog service purges the
catalog tag. The two audiences diverge:
- The edge drops every response tagged
catalogimmediately. The next request repopulates it from Zuplo with fresh data. - Clients keep their copy until their own 60-second
max-ageexpires. No purge reaches a handset.
Sixty seconds of client staleness buys the round trips a client cache saves. Had
both audiences shared one 30-minute max-age, that window would be half an hour
and entirely outside your control. Keep the client TTL far shorter than the edge
TTL: the edge is what you can purge, and a client is not.
Cache authenticated responses at the CDN only
The most valuable configuration is one a single Cache-Control cannot express
at all: the response is identical for every caller but requires an
Authorization header, so the CDN should absorb the load while no browser
stores the body.
Code
Code
This works because a CDN that honors a targeted cache header ignores
Cache-Control entirely for its own decision — it never sees the private. A
private in Cache-Control alone would have suppressed edge caching too, which
is why the targeted header matters here. CDN-only caching needs
strategy: "targeted"; s-maxage cannot express it.
Before enabling this on a route, confirm the response really is identical for every authenticated caller, and that the edge cache key includes whatever distinguishes them if it is not.
Survive a backend outage with staleIfError
edge.staleIfError is often the highest-value directive on an API. It tells the
CDN how long it may keep serving the last good response while the gateway
returns errors, and a read endpoint that keeps working through a backend outage
beats one that returns 503.
Code
Code
A backend that fails at 3 a.m. no longer takes reads down with it: the edge
answers from its last good copy for up to a day while you fix the cause.
staleWhileRevalidate covers the smaller case — once the TTL expires the edge
serves the stale copy immediately and refreshes in the background, so no caller
waits for the refill.
Akamai cannot express the stale windows
Akamai's Edge-Control header has no equivalent of stale-while-revalidate or
stale-if-error. The policy rejects that combination rather than dropping the
directives and letting you believe stale serving is configured. On Akamai,
either configure stale serving in Property Manager or set strategy: "s-maxage"
so the directives travel in Cache-Control. See
Akamai CDN caching for the property setup.
Purge by tag
A tag labels a cached response so you can purge a slice of the edge cache later
— every response tagged cities after a city sync — without waiting out the TTL
or purging everything.
Tag by entity, not just by route. A catalog tag forces you to purge the
whole catalog when one product changes; adding product-8891 lets the write
path purge exactly what it touched. Per-entity tags depend on the request, so
they come from a per-response cache rule rather
than the static tags array:
Code
Tags are validated against the target CDN's limits before they are emitted:
| CDN | Max tag length | Max tags | Max header size |
|---|---|---|---|
| Akamai | 128 chars | 128 | 8192 bytes |
| Fastly | 1024 bytes | — | 16384 bytes |
| Cloudflare | 1024 chars | — | 16384 bytes |
Validation matters because the failure mode is silent. Fastly ignores the key it
is parsing and every key after it once a limit is hit, so one over-long tag
makes later tags quietly unpurgeable. Akamai additionally rejects spaces,
commas, colons and brackets. Static tags in config/policies.json fail as a
configuration error you fix before deploying; tags returned from a function are
dropped with a warning instead, because a 200 should not become a 500 over a
purge tag.
Calling the purge
The policy emits tags. It does not purge. Purging is an API call your own tooling makes when the underlying data changes. On Fastly that is one request per surrogate key:
Code
Akamai's equivalent is a Fast Purge call to /ccu/v3/invalidate/tag/production
with the tags in the body, signed with EdgeGrid credentials; Cloudflare takes a
tags array on its purge endpoint. Wire whichever one you use into the service
that writes the data, so a product update purges product-8891 as part of
saving it.
Safety defaults
Three defaults keep a broadly applied cache policy from causing an incident.
client.mode defaults to strictest. When the upstream already sent a
Cache-Control, the policy emits the more conservative of the two: the lower
max-age and the union of the restrictive directives. Loosening a cache
directive is a data-disclosure risk, not only a performance one — if your
application returns Cache-Control: private for a per-user response and the
policy widened it to public, a shared cache could serve one user's data to the
next. Use replace when the gateway should be the authority on client caching,
or preserve to leave the upstream header alone and add only the CDN headers.
respectUpstream defaults to true. An upstream Cache-Control of
no-store, no-cache or private, or a Set-Cookie on the response,
suppresses every edge header and purge tag. Your application keeps the final say
on what is cacheable, and that holds even when the policy is attached broadly.
Setting it to false is the deliberate override; on Akamai that emits
Edge-Control: !no-store.
vary is off by default. Akamai skips caching any response carrying a
Vary header until Cache ID Modification is configured in Property Manager, so
enabling vary can disable edge caching entirely while every response still
looks correct. The policy logs a warning when vary is set with cdn: akamai.
When a response genuinely varies by a request header, configure the cache key at
the CDN — Cache ID Modification on Akamai, Cache Keys on Cloudflare — rather
than relying on Vary.
Verify it works
Send a request to your Zuplo URL and inspect the response headers:
Code
For the Akamai configuration above, expect:
Code
A missing edge-control usually means the response carried a Set-Cookie or an
upstream no-store: with respectUpstream: true, either one suppresses the
edge headers by design.
That curl proves the gateway emits the right headers. Whether the CDN acts on
them is CDN configuration:
- Akamai — the property's caching behavior must be set to honor origin
Cache-Control, and Cache Tag Visibility controls whetherEdge-Cache-Tagreaches end users. See Akamai CDN caching. - Cloudflare — setting
Cloudflare-CDN-Cache-Controlturns Origin Cache Control on regardless of the dashboard setting, but a Cache Rule still has to make the path eligible for caching.
Confirm the offload at the edge rather than at the gateway: check the CDN's hit ratio for the path, or send the same request twice and look for the second one missing from your Zuplo logs.
Don't stack this with gateway caching
One cache per route
Do not use this policy and the Caching policy on the same route. They are alternative places to cache, not layers that stack, and combining them gives you neither reliably.
The Caching policy serves a hit from an inbound policy, which short-circuits the
pipeline before outbound policies run, so this policy never executes on a
gateway cache hit. The CDN then receives the copy that the Caching policy
stored, and the Caching policy sanitizes every copy it stores: it drops several
CDN cache headers (exactly which ones depends on the CDN) and overwrites
Cache-Control with its own s-maxage (its TTL, 60 seconds by default). The
edge and client TTLs collapse into one number.
Cache misses behave correctly, which is what makes this dangerous. Misses run
the full outbound stack and produce exactly the headers you configured, so the
bug stays invisible while you test and appears only once traffic warms the
cache. Nothing logs it. Pick one: this policy on its own, or
cache at the gateway and let that policy own the
Cache-Control it sends.
Next steps
- Per-response cache rules — a
cacheConfigfunction for anything the static options cannot express: per-entity purge tags, a TTL that depends on a response header, or skipping an empty result set. - Akamai CDN caching — the Property Manager side of the setup.
- Cache at the gateway — when no CDN sits in front of Zuplo, or the cache needs to sit closer to the backend.
- CDN Cache Control policy reference — every configuration option in detail.