Per-response cache rules
Static policy options answer one question: how should this route cache? That's enough for a catalog endpoint that always caches for 30 minutes. It runs out when the answer depends on the request or the response in front of you:
- This response is about city 42 and cinema 7, so it needs the purge tags
city-42andcinema-7. A statictagsarray can't know the identifiers. - This list endpoint returned zero rows because a backend dependency timed out. A 30 minute edge TTL turns a two second blip into a 30 minute outage.
- These screening times change in four minutes. Fifteen minutes at the edge is wrong for this response and right for the next one.
The cacheConfig option on the
CDN Cache Control policy points at
a function that runs on the outbound response and decides those cases. Read
Cache at the CDN first for the static options and the
edge/client split; this page assumes them.
The function returns intent, not headers
The function never writes a header. It returns an edge TTL, a client TTL, and a
list of purge tags, and the policy renders that intent into whichever dialect
the cdn option names.
That separation keeps a CDN migration cheap. Tags joined by commas on Akamai are
joined by spaces on Fastly, and the header names differ on all three. A function
returning raw headers would need a rewrite; one returning
tags: ["catalog", "city-42"] keeps working when cdn changes, with the vendor
limits on tag length and charset still enforced for you.
Code
The signature is
(response: Response, request: ZuploRequest, context: ZuploContext), and the
function may be synchronous or async.
What the return value means
| Return | Effect |
|---|---|
undefined/null | Use the static edge, client, and tags 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. |
Returning nothing is what makes this design work. The static options stay the enforced default for the route and the function handles only exceptions, so a gap in the function degrades to the configured behavior rather than to no caching at all.
{ cache: false } is a positive statement that a response must not be stored
anywhere, so it disables client caching too. The exception is
client.mode: "preserve", which leaves the upstream Cache-Control alone and
suppresses only the edge headers. To skip the edge while clients keep caching
normally, use preserve, or return a config with a client policy and no
edge.
A returned config replaces the static options wholesale
There is no field-by-field merge. With the configuration above, a function that
returns { edge: { maxAge: 60 } } sends a 60 second edge TTL and nothing else:
no staleIfError, no client max-age, and no catalog tag.
Restate every directive the response needs, every time the function returns a
config.
Derive purge tags from the request
Per-entity tags are the main thing static configuration cannot express, and they
turn purging from a blunt instrument into a precise one. A tag of cities
forces a city sync to purge every city. A tag of city-42 purges one.
Code
Both guards exist because the failure mode is silent.
The charset filter. Query parameters carry whatever a caller sends.
?cityId=not a city produces the tag city-not a city, which Akamai rejects
and Fastly reads as three separate keys. The policy validates tags and drops a
bad one with a warning rather than failing a 200, but filtering at the source
keeps the log clean and makes the rule explicit.
The cap. A caller can repeat ?cityId= fifty times, and once the tag header
passes the CDN's size limit the overflow is dropped — on Fastly, along with
every key after it. Capping entity tags bounds that and leaves room for the
route tags you always want present.
Keep route TTLs in one file
Past a handful of routes, TTLs scattered across separate policy instances stop being reviewable. Put the matrix in its own module and look up the current path.
Code
Code
One file now holds every TTL, one diff shows every change to them, and a unit test can assert on the matrix without a running gateway. Refusing an unlisted path makes adding a route a deliberate act rather than an accident.
staleIfError is not expressible in Akamai's Edge-Control. On Akamai, either
set strategy to s-maxage — as the
config/policies.json example above
does — or configure stale serving in Property Manager. The policy rejects the
combination rather than dropping the directive silently.
Refuse to cache a suspicious response
A 200 with an empty collection is ambiguous. The city may genuinely have no
cinemas, or a backend dependency may have timed out and degraded to an empty
list. Caching the second case for 30 minutes across every edge node stretches a
brief incident into a long one, and backend recovery does not clear it.
Code
Prefer a header over parsing the body. A header check costs a map lookup;
parsing JSON costs a full deserialization of every cacheable response on the
gateway's hottest path. Backends often already expose something usable: a result
count, a circuit breaker's x-degraded flag, a partial-content marker. When
none exists, adding one beats paying for the parse forever.
Read the body only when you must
When no header will do, clone the response before reading it.
Code
The function receives the live response, not a copy, and the policy still has to forward that body to the caller. Consuming it without cloning makes that impossible, so the policy fails the request with an error naming the cause rather than sending a truncated response.
The policy does not clone for you, on purpose. clone() tees the stream, and
the branch nobody reads holds the whole body in memory until both branches
drain. Cloning every response would charge that cost to the majority of rules
that never look at a payload. See
Safely clone a request or response
for the underlying behavior.
Shorten the TTL as data approaches a change
When the backend knows when data next changes, the edge TTL should not outlive it. A schedule rolling over in four minutes should not sit at the edge for fifteen.
Code
Both clamps earn their place. Without the ceiling, a backend reporting a change eight hours out pins a response at the edge for eight hours. Without the floor, a timestamp seconds away — or already past — produces a TTL so short the edge stops absorbing anything.
The TTL is computed when the response leaves the gateway, and the policy does
not decrement max-age by the response's Age. Treat these values as a hint
that keeps the edge roughly in step with the data, not as a scheduled expiry.
Test the function
Assert on the headers the gateway sends, not on the function's return value. The
return value is intent; the header is the contract with the CDN, and it is where
a wrong cdn setting or a dropped tag shows up.
| What to assert | Akamai | Fastly | Cloudflare |
|---|---|---|---|
Edge TTL, strategy: targeted | Edge-Control | Surrogate-Control | Cloudflare-CDN-Cache-Control |
Edge TTL, strategy: s-maxage | Cache-Control: s-maxage | same | same |
| Purge tags | Edge-Cache-Tag | Surrogate-Key | Cache-Tag |
| Client TTL | Cache-Control: max-age | same | same |
Start the dev server with npx zuplo dev, then run the suite against it with
npx zuplo test --endpoint http://localhost:9000.
Code
Two negative assertions are worth adding: that a { cache: false } branch
really produced no-store with no tag header, and that no Vary slipped in,
since on Akamai a Vary suppresses caching entirely until Cache ID Modification
is configured.
The same tests run against a preview environment or production; only
--endpoint changes. See Testing your API for the
full workflow. Whether the CDN honors these headers is a separate question that
lives in its configuration, so verify at the edge as well as at the gateway.
Next steps
- Cache at the CDN — the static options this function
overrides, and the
edge/clientsplit it returns. - Cache part of a response — when no TTL is right for the whole payload because only part of it is shared.
- Akamai CDN caching — the Property Manager
side, where you configure the stale serving
Edge-Controlcannot express. - CDN Cache Control policy reference — every configuration option in detail.