CDN Cache Control Policy
The CDN Cache Control policy sets the response headers that tell a CDN in front of your gateway how long it may cache a response, and which purge tags that response belongs to — separately from what you tell the browser.
With this policy, you'll benefit from:
- Two audiences, two policies: let the CDN hold a response for 30 minutes while browsers hold it for 60 seconds, so a purge at the edge actually takes effect
- CDN-only caching: cache an authenticated response at the edge without any browser or intermediary storing it
- No dialect to learn: Akamai's
Edge-ControlandEdge-Cache-Tag, Fastly'sSurrogate-Controland space-separatedSurrogate-Key, Cloudflare'sCache-Tag— you configure intent and the policy writes the right headers - Purge tags with guard rails: tags are validated against each CDN's length, count and character limits, because an over-long tag header is silently truncated at the edge rather than rejected
- Safe by default: an upstream
no-store,privateorSet-Cookiekeeps its veto, and the default merge mode never loosens a policy your application deliberately tightened - Dynamic configuration: a function can return per-response TTLs and per-entity purge tags for anything that can't be expressed statically
Set cdn to the CDN actually in front of your gateway. It has no default,
because every CDN reads a different header and a wrong guess produces a
well-formed response with no edge caching at all.
Configuration
The configuration shows how to configure the policy in the 'policies.json' document.
Code
Policy Configuration
name<string>- The name of your policy instance. This is used as a reference in your routes.policyType<string>- The identifier of the policy. This is used by the Zuplo UI. Value should becdn-cache-control-outbound.handler.export<string>- The name of the exported type. Value should beCdnCacheControlOutboundPolicy.handler.module<string>- The module containing the policy. Value should be$import(@zuplo/runtime).handler.options<object>- The options for this policy. See Policy Options below.
Policy Options
The options for this policy are specified below. All properties are optional unless specifically marked as required.
cdn(required)<string>- The CDN in front of this gateway. Required, with no default — each CDN reads a different header for shared-cache TTL and purge tags, and a wrong guess fails silently. Usegenericfor an RFC 9213 CDN that is not listed. Allowed values areakamai,fastly,cloudflare,cloudfront,generic.strategy<string>- How the edge TTL is carried.targeteduses the CDN's own header (Edge-Control,Surrogate-Control,CDN-Cache-Control) and is the only way to cache at the edge without also caching in the browser.s-maxagefolds the edge TTL intoCache-Controlinstead, for a property that honors originCache-Controlbut not the targeted header. Defaults totargeted, except on a CDN that reads no targeted header — CloudFront defaults tos-maxage, which is the only thing it supports. Allowed values aretargeted,s-maxage. Defaults to"targeted".edge<object>- How long the CDN may serve this response. These directives are sent only to the CDN, never to the browser (unlessstrategyiss-maxage).maxAge(required)<integer>- Seconds the CDN may serve this response without revalidating.staleWhileRevalidate<integer>- Seconds the CDN may keep serving a stale response while it revalidates in the background. Not expressible in Akamai'sEdge-Control— configure it in Property Manager, or usestrategy: s-maxage.staleIfError<integer>- Seconds the CDN may serve a stale response when the gateway returns an error. Not expressible in Akamai'sEdge-Control— configure it in Property Manager, or usestrategy: s-maxage.
client<object>- TheCache-Controlsent to the browser or app. Separate fromedgeso the CDN can absorb load without clients holding stale data.maxAge<integer>- Seconds the client may reuse this response. Omit to send nomax-age.visibility<string>-publicallows any cache to store the response,privaterestricts it to the client's own cache,no-storeforbids client caching entirely. Withstrategy: targetedaprivate/no-storeclient policy still permits edge caching, because the CDN reads its own header. Allowed values arepublic,private,no-store.noCache<boolean>- Addsno-cache, so the client must revalidate before reusing the response. Distinct fromvisibility: no-store, which forbids storing it at all. Defaults tofalse.mustRevalidate<boolean>- Addsmust-revalidate, so the client may not serve the response once stale. Defaults tofalse.mode<string>- How to combine the configured client policy with aCache-Controlthe upstream already sent.strictest(default) emits the more conservative of the two — the lowermax-ageand the union of restrictive directives.replaceoverwrites whatever upstream sent.preserveleaves the upstream header untouched and only adds the CDN headers. Allowed values arestrictest,replace,preserve. Defaults to"strictest".
tags<string[]>- Purge tags, rendered in the target CDN's format: comma-joinedEdge-Cache-Tag(Akamai), space-joinedSurrogate-Key(Fastly), comma-joinedCache-Tag(Cloudflare). CloudFront has no tag purge and rejects this option. Tags are validated against the CDN's length and character limits, because an over-long tag header is silently truncated rather than rejected. Defaults to[].vary<string[]>- Request headers that vary this response, unioned with anyVarythe upstream already sent and emitted asVary. The union is deliberate: replacing an upstreamVary: Authorizationwould stop a shared cache keying on it and let it serve one user's response to another. Off by default: Akamai skips caching any response carryingVaryuntil Cache ID Modification is configured, so enabling this can silently disable edge caching entirely. Defaults to[].respectUpstream<boolean>- When true (default), an upstreamCache-Controlofno-store/no-cache/private, or aSet-Cookieon the response, suppresses all edge headers and purge tags. This keeps the application's ability to mark a response uncacheable. Set to false to override it — on Akamai this emitsEdge-Control: !no-store. Defaults totrue.cacheConfig<object>- A function that overrides the cache configuration per response, for rules that cannot be expressed statically (per-entity purge tags, a TTL that depends on a response header, or skipping an empty result set). Returning nothing (undefinedornull) falls back to theedge,clientandtagsoptions, so those stay the enforced default and the function only handles exceptions. A returned config **replaces** those options wholesale rather than being merged directive by directive, so include every directive the response needs. Return{ cache: false }to skip caching a response entirely — note that also sendsCache-Control: no-storeto the client unlessclient.modeispreserve. The function receives the live response: it must not consume the body, and should callresponse.clone()if it needs to read it.export(required)<string>- Specifies the export that holds your cache config function, e.g.default,cdnCacheConfig. Defaults to"default".module(required)<string>- Specifies the module that holds your cache config function, in the format$import(./modules/my-module). Defaults to"".
Using the Policy
The CDN Cache Control policy sets the response headers a CDN in front of your
gateway reads to decide how long it may cache a response and which purge tags
that response belongs to. It keeps that policy separate from the Cache-Control
you send to the browser.
Why the two are separate
A single Cache-Control header has to serve two audiences with different needs.
If you write Cache-Control: public, max-age=1800 so your CDN holds a response
for 30 minutes, you have also told every browser and mobile app to hold it for
30 minutes. When the underlying data changes you can purge the edge in seconds —
but no purge reaches those clients, and they keep serving stale data until the
TTL runs out.
This policy writes the edge TTL into a header only the CDN reads, so the two can differ:
Code
The CDN absorbs the traffic for half an hour, clients re-check every minute, and
a purge by the cities tag takes effect immediately for everyone.
Choosing the 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 for
anyone using a different one.
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 listed above.
Example Configuration
Code
On Fastly the same options emit space-separated surrogate keys, which is the format Fastly's purge API expects:
Code
CDN-only caching
The most valuable configuration is one a single Cache-Control cannot express:
the response is identical for every caller but requires an Authorization
header, so you want the CDN to absorb the load while no browser stores it.
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.
edge
How long the CDN may serve the response.
maxAge— seconds the CDN may serve the response without revalidating.staleWhileRevalidate— seconds the CDN may keep serving a stale response while it revalidates in the background.staleIfError— seconds the CDN may serve a stale response when the gateway returns an error. This is often the highest-value directive for an API: it keeps reads working through a backend outage.
Akamai's Edge-Control header cannot express the two stale-* windows. If you
need them on Akamai, either configure stale serving in Property Manager or set
strategy to s-maxage. The policy rejects the combination rather than
dropping the directives silently.
client
The Cache-Control sent to the browser or app.
maxAge— seconds the client may reuse the response.visibility—public,private, orno-store.noCache— addsno-cache, so the client revalidates before reusing the response. Distinct fromvisibility: no-store, which forbids storing it at all.mustRevalidate— addsmust-revalidate, so the client may not serve the response once stale.mode— how to combine this with aCache-Controlthe upstream already sent.
client.mode
| Mode | Behavior |
|---|---|
strictest | Default. Emits the more conservative of the two — the lower max-age, and the union of restrictive directives. |
replace | Overwrites whatever the upstream sent. |
preserve | Leaves the upstream Cache-Control untouched and only adds the CDN headers. |
strictest is the default because 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 that to
public, a shared cache could serve one user's data to the next. Use replace
when you intend the gateway to be the authority on client caching.
strategy
How the edge TTL reaches the CDN.
targeted(default) — the CDN's own header. Required for CDN-only caching.s-maxage— folds the edge TTL intoCache-Controlass-maxage, for a property that honors originCache-Controlbut not the targeted header.
On CloudFront the default is s-maxage, since it reads no targeted header
and there is nothing to choose between. You only need to set strategy
explicitly when you want s-maxage on a CDN that does read a targeted header
— for example an Akamai property that honors origin Cache-Control but has not
enabled Edge-Control. Setting strategy: "targeted" on CloudFront is a
build-time error, because that is a contradiction rather than an omission.
Either way, the policy removes any targeted header — or purge-tag header —
your backend already set that the configured CDN reads, unless the policy is
writing that header itself. A targeted header outranks Cache-Control, so an
upstream Surrogate-Control: max-age=99999 reaching Fastly would silently beat
the TTL configured here — particularly under s-maxage, where the policy's own
TTL is in Cache-Control. Only the configured vendor's headers are touched: on
Cloudflare that means both Cloudflare-CDN-Cache-Control and
CDN-Cache-Control, while on CloudFront (which reads neither) nothing is
removed.
That includes the tag header when you configure no tags: a backend-set
Surrogate-Key was never checked against the vendor's limits, so it is cleared
rather than passed through unvalidated.
If your backend is the one that should own the edge policy, do not apply this
policy to that route. There is no configuration that lets a backend-set
targeted header through: edge is required unless cacheConfig supplies it at
runtime, and the vendor's headers are stripped on every path — including when a
response is opted out with cache: false, which removes them and writes nothing
in their place. client.mode: "preserve" preserves the client
Cache-Control only; it has no bearing on the edge headers.
tags
Purge tags, rendered in the target CDN's format. Tags let you invalidate a slice
of the edge cache — every response tagged cities after a city sync — without
waiting for the TTL or purging everything.
Tags are validated against the 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 |
This 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 an over-long tag makes later tags quietly unpurgeable. Akamai additionally rejects spaces, commas, colons and brackets in tag values.
A tag that violates a limit is dropped and logged as a warning rather than failing the response — a 200 should not become a 500 over a purge tag — except in static configuration, where it is a configuration error you can fix before deploying.
CloudFront has no purge-by-tag support; use path invalidation instead.
respectUpstream
When true (the default), 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 ability to mark a response
uncacheable, and that keeps working even if the policy is applied broadly.
Set it to false to override the upstream. On Akamai this emits
Edge-Control: !no-store, the vendor's own directive for caching at the edge
despite a no-store.
vary
Request headers that vary the response, emitted as Vary. This is off by
default for a reason specific to Akamai: Akamai skips caching any response
carrying a Vary header until Cache ID Modification is configured in Property
Manager. Turning this on can therefore disable edge caching entirely while the
response still looks correct. The policy logs a warning when vary is set
together with cdn: akamai.
The names you configure are unioned with any Vary the upstream already
sent, deduplicated case-insensitively; Vary: * from either side absorbs the
rest. Unlike the edge headers, Vary is still emitted when an upstream veto or
cache: false suppresses edge caching — a private, max-age=300 response is
uncacheable at the edge but still cached by the browser, and it needs the variant
key just as much. The policy never narrows an upstream Vary, because doing so silently is
unsafe: if the origin sent Vary: Authorization and you configure
vary: ["accept-language"], replacing it would stop a shared cache keying on
Authorization and let it serve one user's authorized response to another. To
deliberately drop a wasteful upstream Vary — Vary: User-Agent shreds hit
rates — remove it in a separate outbound policy before this one runs.
If your 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.
cacheConfig
For rules that cannot be expressed statically, a function can return the cache configuration per response.
The static options stay the enforced default: a function that returns nothing falls back to them. So you configure the policy once for the route, and the function only has to handle the exceptions.
Code
When the function does return a config, that config replaces the static
options rather than being merged into them field by field — so a returned
{ edge: { maxAge: 60 } } drops a statically configured staleIfError and the
static tags. Return the complete policy for the responses you override.
Reading the response body
The function receives the live response, not a copy. Do not consume the body — the policy still has to forward it, and reading it first makes that impossible. If you need to inspect the payload, clone it:
Code
Cloning is left to you rather than done for every response: clone() tees the
stream, and a branch nobody reads keeps the whole body in memory. Most rules can
avoid the body entirely — a response header such as x-result-count is cheaper
than parsing JSON on every cacheable response.
If the body is consumed without cloning, the policy fails the request with a message naming the cause, rather than letting the response go out truncated.
The three return values:
| Return | Effect |
|---|---|
undefined/null | Use the static options unchanged. |
| a config object | Use it in place of the static options. |
{ cache: false } | Emit no edge headers or tags, and send Cache-Control: no-store to the client — except under client.mode: "preserve", which is a trap; see below. |
{ cache: false } is a positive statement that the response must not be cached
anywhere, so it disables client caching as well as edge caching.
Under client.mode: "preserve" it does not, and this is a sharp edge. The
policy suppresses its own edge headers but leaves the upstream Cache-Control
alone — and that header is exactly what a CDN falls back to once the targeted
header is gone. An upstream Cache-Control: public, max-age=300 therefore keeps
the response cacheable at the edge for five minutes despite the cache: false.
On CloudFront, which reads no targeted header at all, cache: false under
preserve changes nothing whatsoever.
To make the edge skip a response while clients still cache it normally, return
{ edge: { maxAge: 0 } } with client.mode: "preserve". That states a zero
edge TTL in the CDN's own dialect — Surrogate-Control: max-age=0 on Fastly,
s-maxage=0 appended to the client's own directives on CloudFront — rather than
relying on the absence of a header to mean "do not cache".
Code
Code
The function returns intent, not headers: the policy renders it into whichever
dialect cdn names, so switching CDNs is a one-line configuration change rather
than a rewrite. Per-entity tags like city-42 are the main thing that cannot be
expressed statically, and they are what makes fine-grained purging possible.
Note that { cache: false } and undefined are not the same:
cache: false suppresses caching for that response, while undefined falls
back to the static options.
Using this with the Caching policy
This policy sets headers for a CDN outside Zuplo. The Caching policy is a separate, gateway-side response cache.
Do not use the two on the same route. They are alternative places to cache, not layers that stack, and combining them gives you neither policy reliably.
The Caching policy serves a hit from an inbound policy, which short-circuits
the pipeline before outbound policies run — so this policy does not execute on a
gateway cache hit. The CDN instead receives the copy the Caching policy stored,
which it sanitizes on the way in: several CDN headers are removed and
Cache-Control is overwritten with its own s-maxage (60s by default). Cache
misses behave correctly, so the problem is invisible until traffic warms the
cache.
Pick one:
- Cache at the CDN — use this policy alone. Purges are global and fast, and
the
edge/clientsplit works as configured. - Cache at the gateway — use the Caching policy alone, and let it own the
Cache-Controlit sends.
Best Practices
- Make the client TTL much shorter than the edge TTL. The edge is what you
can purge; clients are not. A short client
max-agewith a longedge.maxAgegives you both offload and control. - Set
staleIfErroron read endpoints where CDN support allows it. Serving slightly stale data through a backend outage is almost always better than serving an error. - Tag by entity, not just by route.
city-42lets you purge one city;citiesalone forces you to purge them all. - Leave
respectUpstreamon so the application retains the final say. - Verify at the edge, not just at the gateway.
curl -Iagainst your Zuplo URL shows the headers; whether the CDN honors them depends on its configuration (Akamai's "Honor origin Cache-Control", Cloudflare's Origin Cache Control, a Cache Rule that makes the path cacheable at all).
Limitations
- Purge tags are emitted, not purged. Invalidation is an API call to your CDN from your own tooling.
- Edge cache-key configuration is not expressible in response headers and remains CDN configuration.
- CloudFront supports neither a targeted cache header nor purge tags.
Read more about how policies work