About netlify-caching
netlify-caching is a guide for Netlify developers on configuring caching for dynamic responses and individual components. It covers CDN headers, cache variation, invalidation, the Cache API, and debugging.
Key features
- 01Configure CDN cache headers
- 02Vary cache keys by request
- 03Purge caches by tag
- 04Read and write with Cache API
- 05Inspect status and revalidate
Use cases
- 01Cache dynamic Function responses
- 02Vary content by region, cookie, or query
- 03Purge tagged content after updates
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ netlify/ context-and-tools --skill netlify-cachingAfter installing, type/netlify-cachingin Claude Code to confirm it loaded.
Details
From the project READMENetlify caching
Cache-control header to reach for
Dynamic responses (Functions, Edge Functions, proxies) are NOT cached by default. You must opt in. Set Netlify-CDN-Cache-Control on the response:
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'
}
});
};
Header choice (most specific wins; CDN-Cache-Control/Cache-Control always pass downstream):
Netlify-CDN-Cache-Controlapplies to the Netlify CDN only. Reach for this.CDN-Cache-Controlapplies to all CDNs that support it.Cache-Controlapplies to any CDN or the browser.
Legacy path to avoid: On-demand Builders do not 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.
Footguns (read first)
- Static assets are fresh for up to a year. A shorter
max-ageis ignored. They change only on a new deploy or manual purge. - basic-auth on ANY page disables caching for the ENTIRE site.
durableis serverless-only. It has no effect on Edge Function responses.- Cache API silently drops responses over 10 MB.
cache.put()doesn't store them and later lookups miss. Compress the content or split it across multiple entries. - Never opt sensitive content out of automatic invalidation. It can stay publicly cached after deploys/firewall changes.
Directives
publiccaches the response.privatecaches it in the browser only, not in Netlify's shared cache.no-storeprevents caching.s-maxage=Nsets seconds in Netlify's shared cache (overridesmax-agethere).max-age=Nsets seconds in any cache.stale-while-revalidate=Nserves stale for N seconds after expiry while revalidating in the background.durable(serverless only) stores the response in Netlify's durable cache so other edge nodes reuse it instead of re-invoking the function.
Defaults when no header is set:
- Static:
Netlify-CDN-Cache-Control: public, s-maxage=31536000, must-revalidate - Dynamic:
Cache-Control: public, max-age=0, must-revalidate
Cache key variation — Netlify-Vary
Comma-delimited instructions on the response; pipe-delimited value lists:
Netlify-Vary: query=item_id|page, country=es+de|us, cookie=ab_test|is_logged_in
query=a|bvaries on a subset; barequeryvaries on all params. Keys are case-sensitive; param order is irrelevant.header=Device-Type|App-Versionsupports custom and most standard headers.language=en|es+ptuses+for groups. It is checked againstAccept-Languagewith quality weighting.country=us|es+ptuses GeoIP with ISO 3166-1 two-letter codes;+groups.cookie=ab_test|is_logged_intargets specific keys, not the wholeCookieheader.
Cannot vary by header on: Accept*, Cache-Control, Connection, Content-Length, Cookie, Host, If-*, Range, Referer, Upgrade, User-Agent. For language/cookie/format, use Vary: Accept-Language/Vary: Cookie or the specific Netlify-Vary instruction.
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. Use Vary for format/encoding, and to pass instructions to an upstream CDN like Cloudflare.
Cache tags & opt-out
Tag responses for taggable purging:
Netlify-Cache-Tag: tag1,tag2,tag3
Netlify-Cache-Tag(Netlify CDN) wins overCache-Tag(passed downstream). Some providers stripCache-Tag, so set both when proxying through them.- Constraints: case-insensitive, UTF-8 only, ≤1024 chars/tag, ≤500 tags/response.
Opt a response out of automatic atomic-deploy invalidation with Netlify-Cache-ID. Values are comma-separated, auto-registered as cache tags for purging, and have a separate 500-ID limit:
Netlify-Cache-ID: cms-proxy,product,image
After opting out, purge on demand after relevant changes, e.g. redirect/proxy or function changes behind a Netlify-Cache-ID.
On-demand invalidation (purge)
Purge from a deployed function with purgeCache. The site ID is passed automatically:
import { purgeCache } from "@netlify/functions";
export default async () => {
await purgeCache(); // no args = purge everything for the site
return new Response("Purged!", { status: 202 });
};
Purge by tag, optionally targeting a deploy/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 });
};
Lambda-compatible functions use the legacy module.exports.handler = async (event, context) => {…} signature. They must pass context.clientContext.custom.purge_api_token:
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 };
};
Direct API (from outside a function): POST https://api.netlify.com/api/v1/purge with Authorization: Bearer <personal_access_token> and Content-Type: application/json:
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: pass
site_idorsite_slug. - Purge by tag: pass
cache_tagsplus the site. - Omitting
cache_tagspurges the whole site. An emptycache_tagslist purges NOTHING. - Identifier mapping: in the UI (Project configuration > General > Project details > Project information), Project ID =
site_idand Project name =site_slug. See https://docs.netlify.com/api-and-cli-guides/api-guides/get-started-with-api#get-site. - Rate limit: each tag or site can be purged only twice per 5s. Exceeding it returns
429.
Cache API (caches global)
Programmatic read/write of HTTP responses from Functions/Edge Functions. Use it to cache individual components of a route or arbitrary fetches, alongside header-based route caching.
Scope rule: call caches.open() anywhere, but call match/put/delete only inside the request handler. Doing them at module/global scope throws.
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" };
CacheStorage subset:
caches.match(request)returns aResponsefrom any cache, orundefined.caches.open(name)returns aCache. Distinct names fragment the cache and lower the hit ratio, so use few, meaningful names.
Cache methods (all require caches.open()):
cache.match(request)returns aResponseorundefined.cache.put(request, response)adds a response.cache.add(request)/cache.addAll(requests)fetch and store.cache.delete(request)returnstrue.keys()is not implemented, so there is no way to list contents.
Consistency: reads/writes are strongly consistent. Deletes are eventually consistent, so a deleted entry may still return briefly.
Cannot cache:
- Partial responses (206),
Vary: *, or non-GETmethods. - Responses over 10 MB. These are silently not stored.
Responses also need all of the following, otherwise storage errors:
- a cache-control header with
max-age/s-maxage≥ 1s public(notprivate/no-cache/no-store)- a 2xx status
For responses you don't control, rewrite headers with fetchWithCache.
Limits per invocation: 100 lookups, 20 insertions/deletions. Beyond these, further lookups return nothing and writes/deletes no-op. Limits are shared across edge functions in a request, but separate between serverless and edge functions.
Cache data is per-region (not replicated). It is auto-invalidated on redeploy and on max-age/s-maxage expiry.
@netlify/cache module
Install to get helpers, time constants (MINUTE/HOUR/DAY), and a caches export for local dev:
npm install @netlify/cache
Local-dev workaround: the caches global isn't part of Node.js. Netlify provides it in its Functions/Edge runtimes, both live and under netlify dev. If you run your framework's own dev server, the global is undefined and throws. Import it instead:
import { caches } from "@netlify/cache";
const cache = await caches.open("my-cache");
This requires Netlify CLI 20.0.3+. Nothing persists locally: lookups return nothing, and writes/deletes don't mutate. There is no functional change from the global.
cacheHeaders(settings) → header object
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"],
},
}),
};
For only generic (non-Netlify) headers, use the cdn-cache-control npm module instead.
fetchWithCache(resource, options?, cacheSettings?)
Drop-in fetch that returns a cached response, or fetches, stores, and returns. cacheSettings override conflicting response headers. With swr, background revalidation is handled automatically.
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 | headers | headerString)
Returns { hit, caches: { durable: { hit, stale, stored, ttl }, edge: { hit, stale } } }.
const { hit, edge, durable } = getCacheStatus(response);
needsRevalidation(response) → boolean
Only needed when calling cache.match/cache.put directly (not with fetchWithCache + swr). It is true when a Cache-API response is stale within its SWR window. Return the stale response, then revalidate in context.waitUntil and cache.put the fresh copy:
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;
}
Durable cache
Add durable (serverless only) so edge nodes lacking a local copy check the shared durable cache before invoking the function. This means fewer invocations and better cache-miss latency.
- It is eventually consistent, so multiple regions may still invoke the function a few times per version.
- It is co-located with the site's functions region.
- It works with
Netlify-Vary, SWR, and on-demand invalidation. - Next.js: Next Runtime 5.5.0+ uses the durable cache automatically.
Debugging with Cache-Status
Netlify sets Cache-Status (RFC 9211) on all responses. Check it on a deployed URL. Look for values starting with "Netlify Edge" or "Netlify Durable":
"Netlify Edge"; fwd=missmeans nothing was cached."Netlify Edge"; hitmeans the response was served from cache."Netlify Edge"; hit; fwd=stalemeans a stale response was served while revalidating (SWR)."Netlify Durable"; fwd=uri-miss; stored=true; ttl=3600means a durable miss that was then stored."Netlify Durable"; hit; ttl=1234means a durable hit.
A negative ttl is the number of seconds since expiry. Each request may hit a different cache instance. Without production traffic or durable, expect several empty caches before a hit; repeat requests to warm one.
Netlify house rules (caching)
These are org conventions, not docs facts — merged into the rendered skill by ctx-gen and never generated. Owned by the skills maintainer.
- Only
GETresponses are cached by the CDN.POST/PUT/etc. are never cached regardless of headers — expose cacheable data on aGETroute (put the inputs in the URL or query string). - Without
Netlify-Vary: query=..., the full query string is the cache key — every distinct query string (utm_*,fbclid, ...) is a separate cache entry. Enumerate only the params that actually change the response. netlify devdoes not emulate the CDN cache — a cache miss every time locally is expected, not a bug. Verify caching behavior on a deployed URL (Deploy Preview or production) via itsCache-Statusheader.purgeCache()has ambient credentials only inside a deployed function. From CI, local scripts, or the build, passtoken(a personal access token read from an env var, never hardcoded) andsiteID.