How to control CDN caching on Netlify

Dynamic responses aren't cached by default. Set Netlify-CDN-Cache-Control to opt in, vary the cache key deliberately, purge by tag, and read Cache-Status to debug.

Tutorial

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
DirectiveEffect
publicCache it
privateBrowser only, not Netlify’s shared cache
no-storeDon’t cache
s-maxage=NSeconds in Netlify’s shared cache. Overrides max-age there.
max-age=NSeconds in any cache
stale-while-revalidate=NServe stale for N seconds after expiry while revalidating in the background
durableServerless 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.

InstructionNotes
query=a|bA subset of params, or bare query for all. Keys case-sensitive; param order irrelevant.
header=Device-Type|App-VersionCustom and most standard headers
language=en|es+ptChecked against Accept-Language with quality weighting
country=us|es+ptGeoIP, ISO 3166-1 two-letter codes
cookie=ab_test|is_logged_inSpecific 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":

ValueMeaning
"Netlify Edge"; fwd=missNothing cached
"Netlify Edge"; hitServed from cache
"Netlify Edge"; hit; fwd=staleStale served while revalidating (SWR)
"Netlify Durable"; fwd=uri-miss; stored=true; ttl=3600Durable stored on miss
"Netlify Durable"; hit; ttl=1234Durable 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

MethodReturns
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

LimitValue
Lookups per invocation100
Insertions or deletions per invocation20
ReplicationPer-region, not replicated
InvalidationAutomatic 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.

This article is generated from Netlify’s open-source agent guidance at netlify/context-and-tools, the same reference our AI coding agents use.


Ready to make it faster? Start at netlify.new.