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
      Per-response cache rulesCache part of a responseBuild a custom caching policy
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
Guides

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-42 and cinema-7. A static tags array 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.

Function returns intent
Edge-Control
Surrogate-Control
Cloudflare-CDN-Cache-Control
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.

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
{ "name": "cdn-cache-control", "policyType": "cdn-cache-control-outbound", "handler": { "export": "CdnCacheControlOutboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "cdn": "akamai", "strategy": "s-maxage", "edge": { "maxAge": 300, "staleIfError": 86400 }, "client": { "maxAge": 60, "visibility": "public" }, "tags": ["catalog"], "cacheConfig": { "module": "$import(./modules/cdn-cache)", "export": "default" } } } }

The signature is (response: Response, request: ZuploRequest, context: ZuploContext), and the function may be synchronous or async.

What the return value means

ReturnEffect
undefined/nullUse the static edge, client, and tags options unchanged.
a config objectUse 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
import type { CdnCacheControlConfig, ZuploContext, ZuploRequest, } from "@zuplo/runtime"; /** Akamai rejects spaces, commas, colons, and brackets in a tag value. */ const TAG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; /** Akamai allows 128 tags per object. Stay well clear of the ceiling. */ const MAX_ENTITY_TAGS = 8; const ENTITY_PARAMS = { cityId: "city", cinemaId: "cinema" } as const; function entityTags(url: URL, context: ZuploContext): string[] { const tags = new Set<string>(); for (const [param, prefix] of Object.entries(ENTITY_PARAMS)) { for (const value of url.searchParams.getAll(param)) { if (TAG_PATTERN.test(value)) { tags.add(`${prefix}-${value}`); } else { context.log.warn(`Unusable ${param} left out of the purge tags`); } if (tags.size >= MAX_ENTITY_TAGS) { return [...tags]; } } } return [...tags]; } export default function cdnCacheConfig( response: Response, request: ZuploRequest, context: ZuploContext, ): CdnCacheControlConfig | undefined { // Only GET is safe for a shared cache to replay. if (request.method !== "GET") { return { cache: false }; } const url = new URL(request.url); return { edge: { maxAge: 1800, staleIfError: 86400 }, client: { maxAge: 60, visibility: "public" }, tags: ["catalog", "cities", ...entityTags(url, context)], }; }

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
export interface CacheRouteConfig { /** Seconds the CDN may serve this response. A purge cuts the window short. */ edgeTtl: number; /** Seconds a client may reuse it. Not purgeable, so keep it far shorter. */ clientTtl: number; tags: string[]; } const CACHE_ROUTES: Record<string, CacheRouteConfig> = { "/v1/cities": { edgeTtl: 1800, clientTtl: 300, tags: ["cities"] }, "/v1/movies/now-showing": { edgeTtl: 900, clientTtl: 60, tags: ["movies"] }, "/v1/cinemas": { edgeTtl: 2400, clientTtl: 300, tags: ["cinemas"] }, }; export function getCacheRouteConfig( pathname: string, ): CacheRouteConfig | undefined { // Strip a trailing slash so `/v1/cities` and `/v1/cities/` resolve the same. return CACHE_ROUTES[pathname.replace(/(.)\/$/, "$1")]; }
Code
import type { CdnCacheControlConfig, ZuploContext, ZuploRequest, } from "@zuplo/runtime"; import { getCacheRouteConfig } from "./cache-routes.ts"; /** Seconds the edge may serve a stale copy while the backend is failing. */ const STALE_IF_ERROR = 24 * 60 * 60; export default function cdnCacheConfig( response: Response, request: ZuploRequest, context: ZuploContext, ): CdnCacheControlConfig | undefined { const url = new URL(request.url); const route = getCacheRouteConfig(url.pathname); if (!route) { // Refuse rather than let a static default cache something nobody reviewed. context.log.warn(`No cache entry for ${url.pathname}.`); return { cache: false }; } return { edge: { maxAge: route.edgeTtl, staleIfError: STALE_IF_ERROR }, client: { maxAge: route.clientTtl, visibility: "public" }, tags: route.tags, }; }

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
import type { CdnCacheControlConfig } from "@zuplo/runtime"; export default function cdnCacheConfig( response: Response, ): CdnCacheControlConfig | undefined { // The backend sets x-result-count on every list response. if (response.headers.get("x-result-count") === "0") { return { cache: false }; } // Everything else: fall back to the configured edge, client, and tags. return undefined; }

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
import type { CdnCacheControlConfig } from "@zuplo/runtime"; export default async function cdnCacheConfig( response: Response, ): Promise<CdnCacheControlConfig | undefined> { if (!response.headers.get("content-type")?.includes("application/json")) { return undefined; } const payload = (await response.clone().json()) as { showtimes?: unknown[] }; if (!Array.isArray(payload.showtimes) || payload.showtimes.length === 0) { return { cache: false }; } return { edge: { maxAge: 300, staleIfError: 3600 }, client: { maxAge: 30, visibility: "public" }, tags: ["movies", "showtimes"], }; }

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
import type { CdnCacheControlConfig } from "@zuplo/runtime"; const DEFAULT_EDGE_TTL = 900; const MIN_EDGE_TTL = 30; const MAX_CLIENT_TTL = 60; export default function cdnCacheConfig( response: Response, ): CdnCacheControlConfig | undefined { // x-next-change is the ISO timestamp of the next scheduled change to this // data: the next showtime start, the next price rollover. const nextChange = response.headers.get("x-next-change"); if (!nextChange) { return undefined; } const secondsToChange = Math.floor( (Date.parse(nextChange) - Date.now()) / 1000, ); if (Number.isNaN(secondsToChange)) { return undefined; } const edgeMaxAge = Math.max( MIN_EDGE_TTL, Math.min(DEFAULT_EDGE_TTL, secondsToChange), ); return { edge: { maxAge: edgeMaxAge, staleIfError: 3600 }, client: { maxAge: Math.min(MAX_CLIENT_TTL, edgeMaxAge), visibility: "public", }, tags: ["movies", "showtimes"], }; }

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 assertAkamaiFastlyCloudflare
Edge TTL, strategy: targetedEdge-ControlSurrogate-ControlCloudflare-CDN-Cache-Control
Edge TTL, strategy: s-maxageCache-Control: s-maxagesamesame
Purge tagsEdge-Cache-TagSurrogate-KeyCache-Tag
Client TTLCache-Control: max-agesamesame

Start the dev server with npx zuplo dev, then run the suite against it with npx zuplo test --endpoint http://localhost:9000.

Code
import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; describe("cdn cache headers", () => { it("adds a per-city purge tag", async () => { const url = new URL("/v1/cities?cityId=42", TestHelper.TEST_URL); const response = await fetch(url); const tags = (response.headers.get("edge-cache-tag") ?? "").split(","); expect(response.status).to.equal(200); expect(tags).to.include("cities"); expect(tags).to.include("city-42"); }); it("drops an identifier the CDN charset rejects", async () => { const path = "/v1/cities?cityId=not%20a%20city"; const response = await fetch(new URL(path, TestHelper.TEST_URL)); const tags = (response.headers.get("edge-cache-tag") ?? "").split(","); expect(tags).to.include("cities"); expect(tags.some((tag) => tag.startsWith("city-"))).to.equal(false); }); });

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/client split 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-Control cannot express.
  • CDN Cache Control policy reference — every configuration option in detail.
Edit this page
Last modified on August 4, 2026
Semantic CacheCache part of a response
On this page
  • The function returns intent, not headers
  • What the return value means
  • Derive purge tags from the request
  • Keep route TTLs in one file
  • Refuse to cache a suspicious response
  • Read the body only when you must
  • Shorten the TTL as data approaches a change
  • Test the function
  • Next steps
JSON
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript