# 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.

<Diagram type="sequence" height="h-[520px]">
  <DiagramActor id="client" variant="yellow">
    Client
  </DiagramActor>
  <DiagramActor id="cdn" variant="blue">
    CDN
  </DiagramActor>
  <DiagramActor id="gateway" variant="zuplo">
    Zuplo
  </DiagramActor>
  <DiagramActor id="backend" variant="green">
    Backend
  </DiagramActor>
  <DiagramMessage from="client" to="cdn">
    GET /v1/catalog/products
  </DiagramMessage>
  <DiagramMessage from="cdn" to="gateway">
    Forward to origin
  </DiagramMessage>
  <DiagramMessage from="gateway" to="backend">
    Fetch catalog
  </DiagramMessage>
  <DiagramMessage from="backend" to="gateway">
    200 OK
  </DiagramMessage>
  <DiagramMessage from="gateway" to="cdn">
    200 + Edge-Control + tag
  </DiagramMessage>
  <DiagramMessage from="cdn" to="client">
    200 (stored at the edge)
  </DiagramMessage>
  <DiagramMessage from="client" to="cdn">
    Same request again
  </DiagramMessage>
  <DiagramMessage from="cdn" to="client">
    200, origin never runs
  </DiagramMessage>
</Diagram>

## 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.

```json title="config/policies.json"
{
  "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`:

```json title="config/routes.oas.json (excerpt)"
{
  "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.

| `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](#add-the-policy) above. On Akamai that emits:

```http
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.

```json title="config/policies.json (options excerpt)"
{
  "cdn": "cloudflare",
  "edge": { "maxAge": 300 },
  "client": { "visibility": "private", "maxAge": 0 }
}
```

```http
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.

```json title="config/policies.json (options excerpt)"
{
  "cdn": "fastly",
  "edge": {
    "maxAge": 1800,
    "staleWhileRevalidate": 600,
    "staleIfError": 86400
  },
  "client": { "visibility": "public", "maxAge": 60 }
}
```

```http
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.

:::caution{title="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](../dedicated/akamai/caching.mdx) 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](./dynamic-cache-rules.mdx) rather
than the static `tags` array:

```ts title="modules/cdn-cache.ts"
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:

| 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:

```bash
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:

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

For the Akamai configuration above, expect:

```http
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](../dedicated/akamai/caching.mdx).
- **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

:::caution{title="One cache per route"}

Do not use this policy and the [Caching](../policies/caching-inbound.mdx) 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](./gateway-caching.mdx) and let that policy own the
`Cache-Control` it sends.

:::

## Next steps

- [Per-response cache rules](./dynamic-cache-rules.mdx) — 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](../dedicated/akamai/caching.mdx) — the Property Manager
  side of the setup.
- [Cache at the gateway](./gateway-caching.mdx) — when no CDN sits in front of
  Zuplo, or the cache needs to sit closer to the backend.
- [CDN Cache Control policy reference](../policies/cdn-cache-control-outbound.mdx)
  — every configuration option in detail.
