---
title: "How to control CDN caching on Netlify | Netlify Knowledge Base"
description: "Guides and articles to help you get the most out of the Netlify platform."
source: "https://www.netlify.com/knowledge-base/how-to-control-cdn-caching-on-netlify/"
last_updated: "2026-09-23T19:52:01.000Z"
---
## Key takeaways

Dynamic responses from Functions, Edge Functions, and proxies are **not cached by default**. Opt in by setting `Netlify-CDN-Cache-Control` on the response. Control the cache key with `Netlify-Vary`, tag responses with `Netlify-Cache-Tag` so you can purge them selectively, and check the `Cache-Status` response header on a **deployed** URL to see what actually happened.

-   **Only `GET` is cached.** POST, PUT, and the rest are never cached regardless of headers.
-   **Without `Netlify-Vary: query=...`, the whole query string is the cache key.** Every `utm_*` and `fbclid` variant becomes its own entry.
-   **`netlify dev` does not emulate the CDN cache.** A local miss every time is expected, not a bug.
-   **`durable` is serverless-only.** It has no effect on Edge Function responses.
-   **basic-auth on any page disables caching for the entire site.**
-   **Static assets are fresh for up to a year.** A shorter `max-age` is ignored; they change on deploy or manual purge.

## Caching is opt-in, and that’s the first surprise

If you’ve added a `Cache-Control` header to a Netlify Function and watched it get invoked on every single request, nothing is broken. Dynamic responses aren’t cached unless you opt in, and the header that opts you in isn’t the one most people reach for first.

There’s a second surprise waiting after that: once caching works, your hit rate may be terrible because every marketing URL parameter created a separate cache entry. Both are one-line fixes. The rest of this covers the directives, how to vary the key on purpose, how to invalidate, and how to see what the cache is really doing.

## The header to reach for

```
import type { Context } from "@netlify/functions";
export default async (req: Request, context: Context) => {  return new Response("Hello world", {    headers: {      'Netlify-CDN-Cache-Control': 'public, durable, max-age=60, stale-while-revalidate=120'    }  });};
```

Three headers are available, and the most specific one wins:

-   **`Netlify-CDN-Cache-Control`** applies to Netlify’s CDN only. **This is the one to reach for.**
-   **`CDN-Cache-Control`** applies to all CDNs that support it.
-   **`Cache-Control`** applies to any CDN or the browser.

`CDN-Cache-Control` and `Cache-Control` always pass downstream, which is why `Netlify-CDN-Cache-Control` is the right default: it lets you cache aggressively at Netlify’s edge without telling the visitor’s browser to hold a copy for an hour.

One legacy note: **On-demand Builders don’t support these headers or `Netlify-Vary`.** They use a TTL pattern and key on URL path only. Don’t reach for ODBs in new code.

## When to cache, and when not to

Cache when a response is **reusable across clients**: an expensive API result, a product listing, shared SSR HTML, a generated feed.

Don’t cache when the response is per-client. Middleware, routing logic, and personalization should not be cached. At the edge specifically, caching is for endpoint-style responses and never for middleware. And never opt sensitive content out of automatic invalidation, because it can stay publicly cached after deploys or firewall changes.

If you’re deciding where the caching belongs rather than how to configure it: header-based caching covers whole routes, while the Cache API covers individual components or arbitrary fetches inside a route. They work together.

## Worked example

### Directives

```
public, durable, max-age=60, stale-while-revalidate=120
```

Directive

Effect

`public`

Cache it

`private`

Browser only, not Netlify’s shared cache

`no-store`

Don’t cache

`s-maxage=N`

Seconds in Netlify’s shared cache. Overrides `max-age` there.

`max-age=N`

Seconds in any cache

`stale-while-revalidate=N`

Serve stale for N seconds after expiry while revalidating in the background

`durable`

**Serverless only.** Store in Netlify’s durable cache so other edge nodes reuse it instead of re-invoking the function.

Defaults when you set nothing. Static: `Netlify-CDN-Cache-Control: public, s-maxage=31536000, must-revalidate`. Dynamic: `Cache-Control: public, max-age=0, must-revalidate`.

### Varying the cache key

This is the difference between a useful cache and a cache full of near-duplicates.

```
Netlify-Vary: query=item_id|page, country=es+de|us, cookie=ab_test|is_logged_in
```

Comma-delimited instructions, pipe-delimited value lists, and `+` groups values that should share an entry.

Instruction

Notes

`query=a|b`

A subset of params, or bare `query` for all. Keys case-sensitive; param order irrelevant.

`header=Device-Type|App-Version`

Custom and most standard headers

`language=en|es+pt`

Checked against `Accept-Language` with quality weighting

`country=us|es+pt`

GeoIP, ISO 3166-1 two-letter codes

`cookie=ab_test|is_logged_in`

Specific keys, not the whole `Cookie` header

You **cannot** vary by header on: `Accept*`, `Cache-Control`, `Connection`, `Content-Length`, `Cookie`, `Host`, `If-*`, `Range`, `Referer`, `Upgrade`, `User-Agent`. For language or format, use `Vary: Accept-Language` or the specific `Netlify-Vary` instruction instead. Avoid `Vary: Cookie` — varying on the entire Cookie header destroys cache hit rates because nearly every request carries a unique cookie value; use `Netlify-Vary: cookie=key_name` to vary on specific keys only.

**Consistency rule:** a URL must return the same `Netlify-Vary` on every response. The first cached response’s instructions win and later ones are ignored. `Netlify-Vary` and standard `Vary` are both respected, so use `Vary` for format and encoding, and to pass instructions to an upstream CDN like Cloudflare.

### Tagging and purging

Tag responses so you can invalidate a slice rather than the whole site:

```
Netlify-Cache-Tag: tag1,tag2,tag3
```

`Netlify-Cache-Tag` wins over `Cache-Tag`, which passes downstream. Some providers strip `Cache-Tag`, so set both when you’re proxying through one. Tags are case-insensitive, UTF-8 only, up to 1024 characters each, and up to 500 per response.

Purge from a deployed function:

```
import { purgeCache } from "@netlify/functions";
export default async () => {  await purgeCache(); // no args = purge everything for the site  return new Response("Purged!", { status: 202 });};
```

By tag, optionally scoped to a deploy or subdomain:

```
import { purgeCache } from "@netlify/functions";
export default async (req: Request) => {  const cacheTag = new URL(req.url).searchParams.get("tag");  if (!cacheTag) return;  await purgeCache({    tags: [cacheTag],    deployAlias: "deploy-preview-11",    domain: "early-access.company.com",  });  return new Response("Purged!", { status: 202 });};
```

**Ambient credentials only work inside a deployed function.** From CI, a local script, or the build, pass `token` (a personal access token read from an env var, never hardcoded) and `siteID`.

From outside a function entirely:

```
curl -X POST \  -H "Content-Type: application/json" \  -H "Authorization: Bearer <personal_access_token>" \  --data '{"site_slug": "mysitename", "cache_tags": ["news"], "deploy_alias": "deploy-preview-11", "domain": "early-access.company.com"}' \  'https://api.netlify.com/api/v1/purge'
```

Purge by site with `site_id` or `site_slug`; by tag with `cache_tags` plus a site. Note the asymmetry: **omitting `cache_tags` purges the whole site, but an empty `cache_tags` list purges nothing.**

In the UI under Project configuration > General > Project details, **Project ID** maps to `site_id` and **Project name** maps to `site_slug`.

**Rate limit:** each tag or site can be purged only twice per 5 seconds. Exceeding that returns `429`.

To opt a response out of automatic atomic-deploy invalidation:

```
Netlify-Cache-ID: cms-proxy,product,image
```

These auto-register as cache tags for purging and have their own 500-ID limit. Once you opt out, you own purging after relevant changes.

### The Cache API

Programmatic read and write for individual components of a route or arbitrary fetches.

```
import type { Config, Context } from "@netlify/functions";
const cache = await caches.open("my-cache"); // ok in global scope
export default async (req: Request, context: Context) => {  const request = new Request("https://example.com/expensive-api");  const cached = await cache.match(request);  if (cached) return cached;
  const fresh = await fetch(request);  if (fresh.ok) {    cache.put(request, fresh.clone()).catch((error) => {      console.error("Failed to add to the cache:", error);    });  }  return fresh;};
export const config: Config = { path: "/cache-api-example" };
```

**Scope rule:** `caches.open()` works anywhere, but `match`, `put`, and `delete` work **only inside the request handler**. At module or global scope they throw.

### `@netlify/cache` helpers

```
npm install @netlify/cache
```

`cacheHeaders(settings)` builds the header object for you, with time constants:

```
import { cacheHeaders, DAY } from "@netlify/cache";
const headers = {  "x-custom-header": "some value",  ...cacheHeaders({    ttl: 2 * DAY,          // s-maxage    swr: HOUR,             // stale-while-revalidate    durable: true,    tags: ["product", "sale"],    overrideDeployRevalidation: ["tag"], // opt out of atomic-deploy invalidation    vary: {      cookie: ["ab_test_name", "ab_test_bucket"],      query: ["item_id", "page"], // or true for all      country: ["us", ["es", "pt"]], // nested = OR      language: ["en"],      header: ["Device-Type"],    },  }),};
```

`fetchWithCache` is a drop-in `fetch` that returns a cached response or fetches, stores, and returns. Its `cacheSettings` override conflicting response headers, which is how you cache a response whose headers you don’t control. With `swr`, background revalidation is handled for you:

```
import { fetchWithCache, DAY } from "@netlify/cache";
const response = await fetchWithCache("https://example.com/expensive-api", {  ttl: 2 * DAY,  tags: ["product", "sale"],  vary: { cookie: ["ab_test_name"], query: ["item_id", "page"] },});
```

`getCacheStatus(response)` returns `{ hit, caches: { durable: { hit, stale, stored, ttl }, edge: { hit, stale } } }`.

`needsRevalidation(response)` matters only when you call `cache.match` and `cache.put` directly rather than using `fetchWithCache` with `swr`. It’s true when a Cache-API response is stale inside its SWR window, so you return it and revalidate in the background:

```
if (cached) {  if (needsRevalidation(cached)) {    context.waitUntil(      fetch(request).then((fresh) => {        const response = new Response(fresh.body, {          headers: { ...Object.fromEntries(fresh.headers), ...cacheHeaders({ ttl: MINUTE, swr: HOUR }) },        });        return cache.put(request, response);      })    );  }  return cached;}
```

### Debugging with `Cache-Status`

Netlify sets `Cache-Status` (RFC 9211) on every response. Check it on a **deployed** URL and look for values starting `"Netlify Edge"` or `"Netlify Durable"`:

Value

Meaning

`"Netlify Edge"; fwd=miss`

Nothing cached

`"Netlify Edge"; hit`

Served from cache

`"Netlify Edge"; hit; fwd=stale`

Stale served while revalidating (SWR)

`"Netlify Durable"; fwd=uri-miss; stored=true; ttl=3600`

Durable stored on miss

`"Netlify Durable"; hit; ttl=1234`

Durable hit

A negative `ttl` means seconds since expiry. Each request may reach a different cache instance, so without production traffic or `durable`, expect several empty caches before a hit. Repeat the request to warm one.

## Common failure modes

**Your function is invoked on every request despite cache headers.** Dynamic responses aren’t cached by default and you may be setting the wrong header. Use `Netlify-CDN-Cache-Control`.

**Nothing caches locally.** `netlify dev` doesn’t emulate the CDN cache. Expect a miss every time and verify on a deployed URL via `Cache-Status`.

**Your hit rate is dismal.** Without `Netlify-Vary: query=...`, the full query string is the cache key. Every `utm_source`, `fbclid`, and tracking parameter creates a separate entry. Enumerate only the params that change the response.

**Your POST endpoint won’t cache.** Only `GET` is cached, regardless of headers. Expose the cacheable data on a GET route with the inputs in the URL or query string.

**Caching stopped working site-wide.** Check for basic auth. **basic-auth on any page disables caching for the entire site.**

**`durable` isn’t doing anything.** It’s serverless-only and has no effect on Edge Function responses.

**Your `Netlify-Vary` changes were ignored.** A URL must return the same `Netlify-Vary` on every response. The first cached response’s instructions win.

**A shorter `max-age` on a static asset is ignored.** Static assets are fresh for up to a year and change only on a new deploy or a manual purge.

**Your purge returned `429`.** Each tag or site can be purged twice per 5 seconds.

**Your purge did nothing.** If you passed an **empty** `cache_tags` list, that purges nothing. Omit the field entirely to purge the whole site.

**`purgeCache()` fails from CI or a build.** Ambient credentials only exist inside a deployed function. Pass `token` and `siteID`.

**`caches` is undefined in your framework’s dev server.** The `caches` global isn’t part of Node.js. Netlify provides it in its Functions and Edge runtimes, including under `netlify dev`, but your framework’s own dev server won’t have it. Import it instead:

```
import { caches } from "@netlify/cache";const cache = await caches.open("my-cache");
```

Needs Netlify CLI 20.0.3+. Nothing persists locally: lookups return nothing and writes don’t mutate.

**`cache.put` throws a storage error.** The response probably isn’t cacheable. It needs a cache-control header with `max-age` or `s-maxage` of at least 1 second, `public` (not `private`, `no-cache`, or `no-store`), and a 2xx status. Partial responses (206), `Vary: *`, and non-`GET` methods can’t be cached at all. For responses you don’t control, rewrite the headers with `fetchWithCache`.

**You deleted a cache entry and it came back.** Reads and writes are strongly consistent, but **deletes are eventually consistent**. A deleted entry may return briefly.

**You can’t list what’s in the cache.** `keys()` is not implemented.

**Your cache writes silently stopped mid-request.** Per invocation you get 100 lookups and 20 insertions or deletions. Past that, lookups return nothing and writes no-op. Limits are shared across edge functions in a request but separate between serverless and edge.

## Reference

### Cache API surface

Method

Returns

`caches.match(request)`

`Response` from any cache, or `undefined`

`caches.open(name)`

`Cache`. Distinct names fragment the cache and lower hit ratio, so use few meaningful names.

`cache.match(request)`

`Response` or `undefined`

`cache.put(request, response)`

Adds a response

`cache.add(request)` / `cache.addAll(requests)`

Fetch and store

`cache.delete(request)`

`true`

`keys()`

**Not implemented**

### Cache API limits

Limit

Value

Lookups per invocation

100

Insertions or deletions per invocation

20

Replication

Per-region, not replicated

Invalidation

Automatic on redeploy and on `max-age` / `s-maxage` expiry

### Header precedence

Most specific wins: `Netlify-CDN-Cache-Control`, then `CDN-Cache-Control`, then `Cache-Control`. The latter two always pass downstream.

### Durable cache

Add `durable` (serverless only) so edge nodes without a local copy check the shared durable cache before invoking your function. Fewer invocations and better cache-miss latency. It’s eventually consistent, so several regions may still invoke the function a few times per version. It’s co-located with the site’s functions region and works with `Netlify-Vary`, SWR, and on-demand invalidation. Next.js gets it automatically on Next Runtime 5.5.0+.

### Purge API

`POST https://api.netlify.com/api/v1/purge` with `Authorization: Bearer <personal_access_token>` and `Content-Type: application/json`. Body accepts `site_id` or `site_slug`, `cache_tags`, `deploy_alias`, and `domain`.

Lambda-compatible functions use the legacy handler signature and must pass the token explicitly:

```
import { purgeCache } from "@netlify/functions";
module.exports.handler = async (event, context) => {  const token = context.clientContext.custom.purge_api_token;  await purgeCache({ tags: ["tag1", "tag2"], token });  return { body: "Purged!", statusCode: 202 };};
```

## FAQs

**How do I cache a function response on Netlify?** Set `Netlify-CDN-Cache-Control` on the response, for example `public, durable, max-age=60, stale-while-revalidate=120`. Dynamic responses aren’t cached by default, so this is an opt-in.

**Why isn’t my Netlify Function response being cached?** Either you haven’t set `Netlify-CDN-Cache-Control`, the request isn’t a `GET` (only GET is cached), or you’re testing locally where `netlify dev` doesn’t emulate the CDN cache. Check `Cache-Status` on a deployed URL.

**What’s the difference between `Netlify-CDN-Cache-Control` and `Cache-Control`?** `Netlify-CDN-Cache-Control` applies only to Netlify’s CDN. `CDN-Cache-Control` applies to any CDN that supports it, and `Cache-Control` reaches browsers too. The most specific header wins, and the latter two pass downstream.

**How do I stop query parameters from fragmenting my cache?** Set `Netlify-Vary: query=param1|param2` listing only the params that change the response. Without it, the entire query string is part of the cache key.

**How do I purge the Netlify cache?** Call `purgeCache()` from a deployed function, with `{ tags: [...] }` to purge selectively. From outside a function, POST to `https://api.netlify.com/api/v1/purge` with a personal access token. Each tag or site is limited to two purges per 5 seconds.

**How do I check whether a response was cached?** Read the `Cache-Status` header on a deployed URL. `"Netlify Edge"; hit` means served from cache; `fwd=miss` means nothing was cached.

**Does `stale-while-revalidate` work on Netlify?** Yes. Add `stale-while-revalidate=N` to your cache-control header, and you’ll see `"Netlify Edge"; hit; fwd=stale` while a stale copy is served during revalidation.

**Why can’t I cache my POST endpoint?** Only `GET` responses are cached. Move the cacheable data to a GET route with the inputs in the URL or query string.

**What is the durable cache?** A shared cache that edge nodes check before invoking your function, which cuts invocations and improves cache-miss latency. Add the `durable` directive. It’s serverless-only and has no effect on Edge Functions.

## Related

-   [Netlify API: get started](https://docs.netlify.com/api-and-cli-guides/api-guides/get-started-with-api#get-site)

This article is generated from Netlify’s open-source agent guidance at [netlify/context-and-tools](https://github.com/netlify/context-and-tools), the same reference our AI coding agents use.

* * *

Ready to make it faster? Start at [netlify.new](https://netlify.new).