ZuploZuplo
LoginStart for Free
  • Documentation
  • API Reference
Introduction
Getting Started
    Develop in the portal
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingDynamic MCP Server - Quickstart
    Develop locally with the CLI
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingDynamic MCP Server - Quickstart
Concepts
Development
Policies
Handlers
API Keys
Rate Limiting
Caching
    OverviewCache at the CDNCache at the gateway
    Policies
    Guides
MCP Server
MCP Gateway
AI Gateway
Developer Portal
Monetization
GraphQL
Deploying & Source Control
Analytics
Observability
Networking & Infrastructure
Account Management
Programming API
Build with AI
Zuplo CLI
Migration Guides
Platform LimitsVersion Support PolicySecuritySupportTrust & ComplianceChangelog
powered by Zudoku
Caching

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.

Client
CDN
Zuplo
Backend
GET /v1/catalog/products
Forward to origin
Fetch catalog
200 OK
200 + Edge-Control + tag
200 (stored at the edge)
Same request again
200, origin never runs
Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.

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
{ "policies": [ { "name": "cdn-cache-control", "policyType": "cdn-cache-control-outbound", "handler": { "export": "CdnCacheControlOutboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "cdn": "akamai", "edge": { "maxAge": 1800 }, "client": { "visibility": "public", "maxAge": 60 }, "tags": ["catalog"] } } } ] }

This is an outbound policy, so attach it to the policies.outbound array of the route in config/routes.oas.json:

Code
{ "x-zuplo-route": { "policies": { "outbound": ["cdn-cache-control"] } } }

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.

cdnEdge TTL headerPurge tag headerTag separator
akamaiEdge-ControlEdge-Cache-Tagcomma
fastlySurrogate-ControlSurrogate-Keyspace
cloudflareCloudflare-CDN-Cache-ControlCache-Tagcomma
cloudfrontCache-Control: s-maxagenone—
genericCDN-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
Cache-Control: public, max-age=60 Edge-Control: cache-maxage=30m Edge-Cache-Tag: catalog

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 catalog immediately. The next request repopulates it from Zuplo with fresh data.
  • Clients keep their copy until their own 60-second max-age expires. 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
{ "cdn": "cloudflare", "edge": { "maxAge": 300 }, "client": { "visibility": "private", "maxAge": 0 } }
Code
Cache-Control: private, max-age=0 Cloudflare-CDN-Cache-Control: max-age=300

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
{ "cdn": "fastly", "edge": { "maxAge": 1800, "staleWhileRevalidate": 600, "staleIfError": 86400 }, "client": { "visibility": "public", "maxAge": 60 } }
Code
Cache-Control: public, max-age=60 Surrogate-Control: max-age=1800, stale-while-revalidate=600, stale-if-error=86400

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
return { edge: { maxAge: 1800, staleIfError: 86400 }, client: { maxAge: 60, visibility: "public" }, tags: ["catalog", `product-${productId}`], };

Tags are validated against the target CDN's limits before they are emitted:

CDNMax tag lengthMax tagsMax header size
Akamai128 chars1288192 bytes
Fastly1024 bytes—16384 bytes
Cloudflare1024 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:

TerminalCode
curl -X POST \ -H "Fastly-Key: $FASTLY_API_TOKEN" \ "https://api.fastly.com/service/$FASTLY_SERVICE_ID/purge/product-8891"

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:

TerminalCode
curl -sI https://my-api.zuplo.app/v1/catalog/products

For the Akamai configuration above, expect:

Code
HTTP/2 200 cache-control: public, max-age=60 edge-control: cache-maxage=30m edge-cache-tag: catalog

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 whether Edge-Cache-Tag reaches end users. See Akamai CDN caching.
  • Cloudflare — setting Cloudflare-CDN-Cache-Control turns 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 cacheConfig function 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.
Edit this page
Last modified on August 4, 2026
OverviewCache at the gateway
On this page
  • Add the policy
  • Choose your CDN
  • Split the edge TTL from the client TTL
  • Cache authenticated responses at the CDN only
  • Survive a backend outage with staleIfError
  • Purge by tag
    • Calling the purge
  • Safety defaults
  • Verify it works
  • Don't stack this with gateway caching
  • Next steps
JSON
JSON
JSON
JSON
TypeScript