{"id":27247,"plugin_id":"plugin_asdk_app_691f1f8f72408191afdbbdf8242bdf86","kind":"skill","collection_source":"plugin_package","comparison_source":null,"observed_at":"2026-10-07T00:02:43.437Z","digest":"154d08ee7dac9e1d4a25ef62e25cf812ef1e86caf55a48a9d61fdc0660e2d273","against":24991,"payload":{"description":"Cache dynamic and static responses on Netlify's CDN from Functions, Edge Functions, and proxies. Use when you add caching or cache-control headers to a function response, tune cache TTL or stale-while-revalidate, set up the durable cache, vary a cache key by query/header/cookie/country/language, purge or invalidate the cache by site or cache tag, use the programmatic Cache API (caches.open/match/put) or @netlify/cache helpers (fetchWithCache/cacheHeaders/getCacheStatus), speed up an expensive API call, add ISR or on-demand revalidation, or debug why a response is or isn't cached via the Cache-Status header.","included_files":[],"name":"netlify-caching","skill_md_contents":"---\nname: netlify-caching\ndescription: Cache dynamic and static responses on Netlify's CDN from Functions, Edge Functions, and proxies. Use when you add caching or cache-control headers to a function response, tune cache TTL or stale-while-revalidate, set up the durable cache, vary a cache key by query/header/cookie/country/language, purge or invalidate the cache by site or cache tag, use the programmatic Cache API (caches.open/match/put) or @netlify/cache helpers (fetchWithCache/cacheHeaders/getCacheStatus), speed up an expensive API call, add ISR or on-demand revalidation, or debug why a response is or isn't cached via the Cache-Status header.\n---\n\n# Netlify caching\n\n## Cache-control header to reach for\n\nDynamic responses (Functions, Edge Functions, proxies) are **NOT cached by default** — you must opt in. Set `Netlify-CDN-Cache-Control` on the response:\n\n```ts\nimport type { Context } from \"@netlify/functions\";\n\nexport default async (req: Request, context: Context) => {\n  return new Response(\"Hello world\", {\n    headers: {\n      'Netlify-CDN-Cache-Control': 'public, durable, max-age=60, stale-while-revalidate=120'\n    }\n  });\n};\n```\n\nHeader choice (most specific wins; `CDN-Cache-Control`/`Cache-Control` always pass downstream):\n- `Netlify-CDN-Cache-Control` — Netlify CDN only. **Reach for this.**\n- `CDN-Cache-Control` — all CDNs that support it.\n- `Cache-Control` — any CDN or the browser.\n\n**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.\n\n## Footguns (read first)\n\n- **Only `GET` is cached.** POST/PUT/etc. are never cached regardless of headers — expose cacheable data on a GET route (inputs in the URL or query string).\n- **`netlify dev` does not emulate the CDN cache.** A local cache miss every time is expected. Verify caching on a deployed URL (Deploy Preview or production) via its `Cache-Status` header.\n- **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 change the response.\n- **Static assets are fresh for up to a year** — a shorter `max-age` is ignored. They change only on a new deploy or manual purge.\n- **basic-auth on ANY page disables caching for the ENTIRE site.**\n- **`durable` is serverless-only** — it has no effect on Edge Function responses.\n- Never opt sensitive content out of automatic invalidation — it can stay publicly cached after deploys/firewall changes.\n\n## Directives\n\n- `public` cache it / `private` browser-only, not Netlify's shared cache / `no-store` don't cache.\n- `s-maxage=N` seconds in Netlify's shared cache (overrides `max-age` there).\n- `max-age=N` seconds in any cache.\n- `stale-while-revalidate=N` serve stale for N seconds after expiry while revalidating in background.\n- `durable` (serverless only) store in Netlify's durable cache so other edge nodes reuse it instead of re-invoking the function.\n\nDefaults 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`.\n\n## Cache key variation — `Netlify-Vary`\n\nComma-delimited instructions on the response; pipe-delimited value lists:\n\n```\nNetlify-Vary: query=item_id|page, country=es+de|us, cookie=ab_test|is_logged_in\n```\n\n- `query=a|b` subset, or bare `query` for all params. Keys case-sensitive; param order irrelevant.\n- `header=Device-Type|App-Version` — custom + most standard headers.\n- `language=en|es+pt` — `+` groups; checked against `Accept-Language` with quality weighting.\n- `country=us|es+pt` — GeoIP, ISO 3166-1 two-letter codes; `+` groups.\n- `cookie=ab_test|is_logged_in` — target specific keys, not the whole `Cookie` header.\n\n**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.\n\n**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` + standard `Vary` are both respected (use `Vary` for format/encoding, and to pass instructions to an upstream CDN like Cloudflare).\n\n## Cache tags & opt-out\n\nTag responses for taggable purging:\n\n```\nNetlify-Cache-Tag: tag1,tag2,tag3\n```\n\n- `Netlify-Cache-Tag` (Netlify CDN) wins over `Cache-Tag` (passed downstream). Some providers strip `Cache-Tag` — set both when proxying through them.\n- Constraints: case-insensitive, UTF-8 only, ≤1024 chars/tag, ≤500 tags/response.\n\nOpt a response out of automatic atomic-deploy invalidation with `Netlify-Cache-ID` (comma-separated; auto-registered as cache tags for purging; separate 500-ID limit):\n\n```\nNetlify-Cache-ID: cms-proxy,product,image\n```\n\nAfter opting out, purge on-demand after relevant changes (e.g. redirect/proxy or function changes behind a `Netlify-Cache-ID`).\n\n## On-demand invalidation (purge)\n\nPurge from a **deployed function** with `purgeCache` (site ID is passed automatically):\n\n```ts\nimport { purgeCache } from \"@netlify/functions\";\n\nexport default async () => {\n  await purgeCache(); // no args = purge everything for the site\n  return new Response(\"Purged!\", { status: 202 });\n};\n```\n\nPurge by tag, optionally targeting a deploy/subdomain:\n\n```ts\nimport { purgeCache } from \"@netlify/functions\";\n\nexport default async (req: Request) => {\n  const cacheTag = new URL(req.url).searchParams.get(\"tag\");\n  if (!cacheTag) return;\n  await purgeCache({\n    tags: [cacheTag],\n    deployAlias: \"deploy-preview-11\",\n    domain: \"early-access.company.com\",\n  });\n  return new Response(\"Purged!\", { status: 202 });\n};\n```\n\n**Ambient credentials only work inside a deployed function.** From CI, local scripts, or the build, pass `token` (a personal access token read from an env var — never hardcoded) and `siteID`.\n\n**Lambda-compatible functions** use the legacy `module.exports.handler = async (event, context) => {…}` signature and must pass `context.clientContext.custom.purge_api_token`:\n\n```ts\nimport { purgeCache } from \"@netlify/functions\";\n\nmodule.exports.handler = async (event, context) => {\n  const token = context.clientContext.custom.purge_api_token;\n  await purgeCache({ tags: [\"tag1\", \"tag2\"], token });\n  return { body: \"Purged!\", statusCode: 202 };\n};\n```\n\nDirect API (from outside a function) — `POST https://api.netlify.com/api/v1/purge` with `Authorization: Bearer <personal_access_token>` and `Content-Type: application/json`:\n\n```sh\ncurl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer <personal_access_token>\" \\\n  --data '{\"site_slug\": \"mysitename\", \"cache_tags\": [\"news\"], \"deploy_alias\": \"deploy-preview-11\", \"domain\": \"early-access.company.com\"}' \\\n  'https://api.netlify.com/api/v1/purge'\n```\n\n- Purge by site: `site_id` or `site_slug`. By tag: `cache_tags` + site. Omitting `cache_tags` purges the whole site; an **empty** `cache_tags` list purges NOTHING.\n- Identifier mapping: in the UI (Project configuration > General > Project details), **Project ID** = `site_id`, **Project name** = `site_slug`. See https://docs.netlify.com/api-and-cli-guides/api-guides/get-started-with-api#get-site.\n- **Rate limit:** each tag or site can be purged only twice per 5s — exceeding returns `429`.\n\n## Cache API (`caches` global)\n\nProgrammatic read/write of HTTP responses from Functions/Edge Functions. Use for caching individual components of a route or arbitrary fetches, alongside header-based route caching.\n\n**Scope rule:** `caches.open()` anywhere, but `match`/`put`/`delete` **only inside the request handler** — doing them at module/global scope throws.\n\n```ts\nimport type { Config, Context } from \"@netlify/functions\";\n\nconst cache = await caches.open(\"my-cache\"); // ok in global scope\n\nexport default async (req: Request, context: Context) => {\n  const request = new Request(\"https://example.com/expensive-api\");\n  const cached = await cache.match(request);\n  if (cached) return cached;\n\n  const fresh = await fetch(request);\n  if (fresh.ok) {\n    cache.put(request, fresh.clone()).catch((error) => {\n      console.error(\"Failed to add to the cache:\", error);\n    });\n  }\n  return fresh;\n};\n\nexport const config: Config = { path: \"/cache-api-example\" };\n```\n\n`CacheStorage` subset:\n- `caches.match(request)` → `Response` from any cache, or `undefined`.\n- `caches.open(name)` → `Cache`. Distinct names fragment the cache and lower hit ratio — use few, meaningful names.\n\n`Cache` methods (all require `caches.open()`):\n- `cache.match(request)` → `Response` | `undefined`.\n- `cache.put(request, response)` → adds a response.\n- `cache.add(request)` / `cache.addAll(requests)` → fetch + store.\n- `cache.delete(request)` → `true`.\n- `keys()` is **not implemented** — no way to list contents.\n\nConsistency: reads/writes strongly consistent; **deletes eventually consistent** (a deleted entry may still return briefly).\n\n**Cannot cache:** partial responses (206), `Vary: *`, or non-`GET` methods. Responses need a cache-control header with `max-age`/`s-maxage` ≥ 1s, `public` (not `private`/`no-cache`/`no-store`), and a 2xx status — otherwise storage errors. For responses you don't control, rewrite headers with `fetchWithCache`.\n\n**Limits per invocation:** 100 lookups, 20 insertions/deletions. Exceeding: further lookups return nothing; 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), auto-invalidated on redeploy and on `max-age`/`s-maxage` expiry.\n\n## `@netlify/cache` module\n\nInstall to get helpers, time constants (`MINUTE`/`HOUR`/`DAY`), and a `caches` export for local dev:\n\n```\nnpm install @netlify/cache\n```\n\n**Local-dev workaround:** the `caches` global isn't part of Node.js. Netlify provides it in its Functions/Edge runtimes (live and under `netlify dev`), but if you run your framework's own dev server the global is undefined and throws — import it instead:\n\n```ts\nimport { caches } from \"@netlify/cache\";\nconst cache = await caches.open(\"my-cache\");\n```\n\nRequires Netlify CLI 20.0.3+; nothing persists locally (lookups return nothing, writes/deletes don't mutate). No functional change from the global.\n\n### `cacheHeaders(settings)` → header object\n\n```ts\nimport { cacheHeaders, DAY } from \"@netlify/cache\";\n\nconst headers = {\n  \"x-custom-header\": \"some value\",\n  ...cacheHeaders({\n    ttl: 2 * DAY,          // s-maxage\n    swr: HOUR,             // stale-while-revalidate\n    durable: true,\n    tags: [\"product\", \"sale\"],\n    overrideDeployRevalidation: [\"tag\"], // opt out of atomic-deploy invalidation\n    vary: {\n      cookie: [\"ab_test_name\", \"ab_test_bucket\"],\n      query: [\"item_id\", \"page\"], // or true for all\n      country: [\"us\", [\"es\", \"pt\"]], // nested = OR\n      language: [\"en\"],\n      header: [\"Device-Type\"],\n    },\n  }),\n};\n```\n\nFor only generic (non-Netlify) headers, use the `cdn-cache-control` npm module instead.\n\n### `fetchWithCache(resource, options?, cacheSettings?)`\n\nDrop-in `fetch` that returns a cached response or fetches, stores, and returns. `cacheSettings` override conflicting response headers; with `swr`, background revalidation is handled automatically.\n\n```ts\nimport { fetchWithCache, DAY } from \"@netlify/cache\";\n\nconst response = await fetchWithCache(\"https://example.com/expensive-api\", {\n  ttl: 2 * DAY,\n  tags: [\"product\", \"sale\"],\n  vary: { cookie: [\"ab_test_name\"], query: [\"item_id\", \"page\"] },\n});\n```\n\n### `getCacheStatus(response | headers | headerString)`\n\nReturns `{ hit, caches: { durable: { hit, stale, stored, ttl }, edge: { hit, stale } } }`.\n\n```ts\nconst { hit, edge, durable } = getCacheStatus(response);\n```\n\n### `needsRevalidation(response)` → boolean\n\nOnly needed when calling `cache.match`/`cache.put` directly (not with `fetchWithCache`+`swr`). True when a Cache-API response is stale within its SWR window — return it, then revalidate in `context.waitUntil` and `cache.put` the fresh copy:\n\n```ts\nif (cached) {\n  if (needsRevalidation(cached)) {\n    context.waitUntil(\n      fetch(request).then((fresh) => {\n        const response = new Response(fresh.body, {\n          headers: { ...Object.fromEntries(fresh.headers), ...cacheHeaders({ ttl: MINUTE, swr: HOUR }) },\n        });\n        return cache.put(request, response);\n      })\n    );\n  }\n  return cached;\n}\n```\n\n## Durable cache\n\nAdd `durable` (serverless only) so edge nodes lacking a local copy check the shared durable cache before invoking the function — fewer invocations, better cache-miss latency. Eventually consistent, so multiple regions may still invoke the function a few times per version. Co-located with the site's functions region. Works with `Netlify-Vary`, SWR, and on-demand invalidation. **Next.js:** Next Runtime 5.5.0+ uses the durable cache automatically.\n\n## Debugging with `Cache-Status`\n\nNetlify sets `Cache-Status` (RFC 9211) on all responses. Check it on a **deployed** URL. Look for values starting `\"Netlify Edge\"` or `\"Netlify Durable\"`:\n\n- `\"Netlify Edge\"; fwd=miss` — nothing cached.\n- `\"Netlify Edge\"; hit` — served from cache.\n- `\"Netlify Edge\"; hit; fwd=stale` — stale served while revalidating (SWR).\n- Durable stored on miss: `\"Netlify Durable\"; fwd=uri-miss; stored=true; ttl=3600`.\n- Durable hit: `\"Netlify Durable\"; hit; ttl=1234`.\n\n`ttl` negative = 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.\n\n<!-- Gaps: package/method inconsistency in @netlify/cache local-dev docs (caches import shown with cache.set, not the documented cache.put) resolved to cache.put per Cache API surface. -->\n\n<!-- system: agent-context/caching/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->\n# Netlify house rules (caching)\n\nThese are org conventions, not docs facts — merged into the rendered skill by\nctx-gen and never generated. Owned by the skills maintainer.\n\n1. Only `GET` responses are cached by the CDN. `POST`/`PUT`/etc. are never\n   cached regardless of headers — expose cacheable data on a `GET` route\n   (put the inputs in the URL or query string).\n2. Without `Netlify-Vary: query=...`, the full query string is the cache key —\n   every distinct query string (`utm_*`, `fbclid`, ...) is a separate cache\n   entry. Enumerate only the params that actually change the response.\n3. `netlify dev` does not emulate the CDN cache — a cache miss every time\n   locally is expected, not a bug. Verify caching behavior on a deployed URL\n   (Deploy Preview or production) via its `Cache-Status` header.\n4. `purgeCache()` has ambient credentials only inside a deployed function.\n   From CI, local scripts, or the build, pass `token` (a personal access\n   token read from an env var, never hardcoded) and `siteID`.\n"},"changes":[{"path":"/description","type":"changed","before":"Guide for controlling caching on Netlify's CDN. Use when configuring cache headers, setting up stale-while-revalidate, implementing on-demand cache purge, or understanding Netlify's CDN caching behavior. Covers Cache-Control, Netlify-CDN-Cache-Control, cache tags, durable cache, and framework-specific caching patterns.","after":"Cache dynamic and static responses on Netlify's CDN from Functions, Edge Functions, and proxies. Use when you add caching or cache-control headers to a function response, tune cache TTL or stale-while-revalidate, set up the durable cache, vary a cache key by query/header/cookie/country/language, purge or invalidate the cache by site or cache tag, use the programmatic Cache API (caches.open/match/put) or @netlify/cache helpers (fetchWithCache/cacheHeaders/getCacheStatus), speed up an expensive API call, add ISR or on-demand revalidation, or debug why a response is or isn't cached via the Cache-Status header."},{"path":"/included_files","type":"changed","before":[{"relative_path":"LICENSE.txt","size_in_bytes":10776},{"relative_path":"agents/openai.yaml","size_in_bytes":351},{"relative_path":"assets/netlify-small.svg","size_in_bytes":1291},{"relative_path":"assets/netlify.png","size_in_bytes":2686}],"after":[]},{"path":"/skill_md_contents","type":"changed","before":"---\nname: netlify-caching\ndescription: Guide for controlling caching on Netlify's CDN. Use when configuring cache headers, setting up stale-while-revalidate, implementing on-demand cache purge, or understanding Netlify's CDN caching behavior. Covers Cache-Control, Netlify-CDN-Cache-Control, cache tags, durable cache, and framework-specific caching patterns.\n---\n\n# Caching on Netlify\n\n## Default Behavior\n\n**Static assets** are cached automatically:\n- CDN: cached for 1 year, invalidated on every deploy\n- Browser: always revalidates (`max-age=0, must-revalidate`)\n- No configuration needed\n\n**Dynamic responses** (functions, edge functions, proxied) are **not cached by default**. Add cache headers explicitly.\n\n## Cache-Control Headers\n\nThree headers control caching, from most to least specific:\n\n| Header | Who sees it | Use case |\n|---|---|---|\n| `Netlify-CDN-Cache-Control` | Netlify CDN only (stripped before browser) | CDN-only caching |\n| `CDN-Cache-Control` | All CDN caches (stripped before browser) | Multi-CDN setups |\n| `Cache-Control` | Browser and all caches | General caching |\n\n### Common Patterns\n\n```typescript\n// Cache at CDN for 1 hour, browser always revalidates\nreturn new Response(body, {\n  headers: {\n    \"Netlify-CDN-Cache-Control\": \"public, s-maxage=3600, must-revalidate\",\n    \"Cache-Control\": \"public, max-age=0, must-revalidate\",\n  },\n});\n\n// Stale-while-revalidate (serve stale for 2 min while refreshing)\nreturn new Response(body, {\n  headers: {\n    \"Netlify-CDN-Cache-Control\": \"public, max-age=60, stale-while-revalidate=120\",\n  },\n});\n\n// Durable cache (shared across edge nodes, serverless functions only)\nreturn new Response(body, {\n  headers: {\n    \"Netlify-CDN-Cache-Control\": \"public, durable, max-age=60, stale-while-revalidate=120\",\n  },\n});\n```\n\n### Immutable Assets\n\nFor fingerprinted files (hash in filename):\n\n```toml\n# netlify.toml\n[[headers]]\nfor = \"/assets/*\"\n[headers.values]\nCache-Control = \"public, max-age=31536000, immutable\"\n```\n\n## Cache Tags and On-Demand Purge\n\nTag responses for selective cache invalidation:\n\n```typescript\nreturn new Response(body, {\n  headers: {\n    \"Netlify-Cache-ID\": \"product,listing\",\n    \"Netlify-CDN-Cache-Control\": \"public, s-maxage=86400\",\n  },\n});\n```\n\nPurge by tag:\n\n```typescript\nimport { purgeCache } from \"@netlify/functions\";\n\nexport default async () => {\n  await purgeCache({ tags: [\"product\"] });\n  return new Response(\"Purged\", { status: 202 });\n};\n```\n\nPurge entire site:\n\n```typescript\nawait purgeCache();\n```\n\nResponses with `Netlify-Cache-ID` are **excluded from automatic deploy-based invalidation** — they must be purged explicitly.\n\n## Cache Key Variation\n\nCustomize what creates separate cache entries:\n\n```typescript\nreturn new Response(body, {\n  headers: {\n    \"Netlify-Vary\": \"cookie=ab_test|is_logged_in\",\n    // Other options: query=param1|param2, header=X-Custom, country=us|de, language=en|fr\n  },\n});\n```\n\n## Framework-Specific Caching\n\n### Next.js\nISR uses Netlify's durable cache automatically (runtime 5.5.0+). `revalidatePath` and `revalidateTag` trigger cache purge.\n\n### Astro / Remix\nFull control over cache headers in server routes. Set `Netlify-CDN-Cache-Control` in responses for CDN caching.\n\n### Nuxt\nDefault Nitro preset handles caching. ISR-style patterns use `routeRules` with `swr` or `isr` options.\n\n### Vite SPA\nStatic assets are cached by default. API responses from Netlify Functions need explicit cache headers.\n\n## Debugging\n\nCheck the `Cache-Status` response header:\n- `HIT` — served from cache\n- `MISS` — generated fresh\n- `REVALIDATED` — stale content was revalidated\n\n## Constraints\n\n- Basic auth disables caching for the entire site\n- Durable cache is serverless functions only (not edge functions)\n- Same URL must return identical `Netlify-Vary` headers across responses\n- Deploy invalidation is scoped to deploy context (production vs preview)\n","after":"---\nname: netlify-caching\ndescription: Cache dynamic and static responses on Netlify's CDN from Functions, Edge Functions, and proxies. Use when you add caching or cache-control headers to a function response, tune cache TTL or stale-while-revalidate, set up the durable cache, vary a cache key by query/header/cookie/country/language, purge or invalidate the cache by site or cache tag, use the programmatic Cache API (caches.open/match/put) or @netlify/cache helpers (fetchWithCache/cacheHeaders/getCacheStatus), speed up an expensive API call, add ISR or on-demand revalidation, or debug why a response is or isn't cached via the Cache-Status header.\n---\n\n# Netlify caching\n\n## Cache-control header to reach for\n\nDynamic responses (Functions, Edge Functions, proxies) are **NOT cached by default** — you must opt in. Set `Netlify-CDN-Cache-Control` on the response:\n\n```ts\nimport type { Context } from \"@netlify/functions\";\n\nexport default async (req: Request, context: Context) => {\n  return new Response(\"Hello world\", {\n    headers: {\n      'Netlify-CDN-Cache-Control': 'public, durable, max-age=60, stale-while-revalidate=120'\n    }\n  });\n};\n```\n\nHeader choice (most specific wins; `CDN-Cache-Control`/`Cache-Control` always pass downstream):\n- `Netlify-CDN-Cache-Control` — Netlify CDN only. **Reach for this.**\n- `CDN-Cache-Control` — all CDNs that support it.\n- `Cache-Control` — any CDN or the browser.\n\n**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.\n\n## Footguns (read first)\n\n- **Only `GET` is cached.** POST/PUT/etc. are never cached regardless of headers — expose cacheable data on a GET route (inputs in the URL or query string).\n- **`netlify dev` does not emulate the CDN cache.** A local cache miss every time is expected. Verify caching on a deployed URL (Deploy Preview or production) via its `Cache-Status` header.\n- **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 change the response.\n- **Static assets are fresh for up to a year** — a shorter `max-age` is ignored. They change only on a new deploy or manual purge.\n- **basic-auth on ANY page disables caching for the ENTIRE site.**\n- **`durable` is serverless-only** — it has no effect on Edge Function responses.\n- Never opt sensitive content out of automatic invalidation — it can stay publicly cached after deploys/firewall changes.\n\n## Directives\n\n- `public` cache it / `private` browser-only, not Netlify's shared cache / `no-store` don't cache.\n- `s-maxage=N` seconds in Netlify's shared cache (overrides `max-age` there).\n- `max-age=N` seconds in any cache.\n- `stale-while-revalidate=N` serve stale for N seconds after expiry while revalidating in background.\n- `durable` (serverless only) store in Netlify's durable cache so other edge nodes reuse it instead of re-invoking the function.\n\nDefaults 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`.\n\n## Cache key variation — `Netlify-Vary`\n\nComma-delimited instructions on the response; pipe-delimited value lists:\n\n```\nNetlify-Vary: query=item_id|page, country=es+de|us, cookie=ab_test|is_logged_in\n```\n\n- `query=a|b` subset, or bare `query` for all params. Keys case-sensitive; param order irrelevant.\n- `header=Device-Type|App-Version` — custom + most standard headers.\n- `language=en|es+pt` — `+` groups; checked against `Accept-Language` with quality weighting.\n- `country=us|es+pt` — GeoIP, ISO 3166-1 two-letter codes; `+` groups.\n- `cookie=ab_test|is_logged_in` — target specific keys, not the whole `Cookie` header.\n\n**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.\n\n**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` + standard `Vary` are both respected (use `Vary` for format/encoding, and to pass instructions to an upstream CDN like Cloudflare).\n\n## Cache tags & opt-out\n\nTag responses for taggable purging:\n\n```\nNetlify-Cache-Tag: tag1,tag2,tag3\n```\n\n- `Netlify-Cache-Tag` (Netlify CDN) wins over `Cache-Tag` (passed downstream). Some providers strip `Cache-Tag` — set both when proxying through them.\n- Constraints: case-insensitive, UTF-8 only, ≤1024 chars/tag, ≤500 tags/response.\n\nOpt a response out of automatic atomic-deploy invalidation with `Netlify-Cache-ID` (comma-separated; auto-registered as cache tags for purging; separate 500-ID limit):\n\n```\nNetlify-Cache-ID: cms-proxy,product,image\n```\n\nAfter opting out, purge on-demand after relevant changes (e.g. redirect/proxy or function changes behind a `Netlify-Cache-ID`).\n\n## On-demand invalidation (purge)\n\nPurge from a **deployed function** with `purgeCache` (site ID is passed automatically):\n\n```ts\nimport { purgeCache } from \"@netlify/functions\";\n\nexport default async () => {\n  await purgeCache(); // no args = purge everything for the site\n  return new Response(\"Purged!\", { status: 202 });\n};\n```\n\nPurge by tag, optionally targeting a deploy/subdomain:\n\n```ts\nimport { purgeCache } from \"@netlify/functions\";\n\nexport default async (req: Request) => {\n  const cacheTag = new URL(req.url).searchParams.get(\"tag\");\n  if (!cacheTag) return;\n  await purgeCache({\n    tags: [cacheTag],\n    deployAlias: \"deploy-preview-11\",\n    domain: \"early-access.company.com\",\n  });\n  return new Response(\"Purged!\", { status: 202 });\n};\n```\n\n**Ambient credentials only work inside a deployed function.** From CI, local scripts, or the build, pass `token` (a personal access token read from an env var — never hardcoded) and `siteID`.\n\n**Lambda-compatible functions** use the legacy `module.exports.handler = async (event, context) => {…}` signature and must pass `context.clientContext.custom.purge_api_token`:\n\n```ts\nimport { purgeCache } from \"@netlify/functions\";\n\nmodule.exports.handler = async (event, context) => {\n  const token = context.clientContext.custom.purge_api_token;\n  await purgeCache({ tags: [\"tag1\", \"tag2\"], token });\n  return { body: \"Purged!\", statusCode: 202 };\n};\n```\n\nDirect API (from outside a function) — `POST https://api.netlify.com/api/v1/purge` with `Authorization: Bearer <personal_access_token>` and `Content-Type: application/json`:\n\n```sh\ncurl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Authorization: Bearer <personal_access_token>\" \\\n  --data '{\"site_slug\": \"mysitename\", \"cache_tags\": [\"news\"], \"deploy_alias\": \"deploy-preview-11\", \"domain\": \"early-access.company.com\"}' \\\n  'https://api.netlify.com/api/v1/purge'\n```\n\n- Purge by site: `site_id` or `site_slug`. By tag: `cache_tags` + site. Omitting `cache_tags` purges the whole site; an **empty** `cache_tags` list purges NOTHING.\n- Identifier mapping: in the UI (Project configuration > General > Project details), **Project ID** = `site_id`, **Project name** = `site_slug`. See https://docs.netlify.com/api-and-cli-guides/api-guides/get-started-with-api#get-site.\n- **Rate limit:** each tag or site can be purged only twice per 5s — exceeding returns `429`.\n\n## Cache API (`caches` global)\n\nProgrammatic read/write of HTTP responses from Functions/Edge Functions. Use for caching individual components of a route or arbitrary fetches, alongside header-based route caching.\n\n**Scope rule:** `caches.open()` anywhere, but `match`/`put`/`delete` **only inside the request handler** — doing them at module/global scope throws.\n\n```ts\nimport type { Config, Context } from \"@netlify/functions\";\n\nconst cache = await caches.open(\"my-cache\"); // ok in global scope\n\nexport default async (req: Request, context: Context) => {\n  const request = new Request(\"https://example.com/expensive-api\");\n  const cached = await cache.match(request);\n  if (cached) return cached;\n\n  const fresh = await fetch(request);\n  if (fresh.ok) {\n    cache.put(request, fresh.clone()).catch((error) => {\n      console.error(\"Failed to add to the cache:\", error);\n    });\n  }\n  return fresh;\n};\n\nexport const config: Config = { path: \"/cache-api-example\" };\n```\n\n`CacheStorage` subset:\n- `caches.match(request)` → `Response` from any cache, or `undefined`.\n- `caches.open(name)` → `Cache`. Distinct names fragment the cache and lower hit ratio — use few, meaningful names.\n\n`Cache` methods (all require `caches.open()`):\n- `cache.match(request)` → `Response` | `undefined`.\n- `cache.put(request, response)` → adds a response.\n- `cache.add(request)` / `cache.addAll(requests)` → fetch + store.\n- `cache.delete(request)` → `true`.\n- `keys()` is **not implemented** — no way to list contents.\n\nConsistency: reads/writes strongly consistent; **deletes eventually consistent** (a deleted entry may still return briefly).\n\n**Cannot cache:** partial responses (206), `Vary: *`, or non-`GET` methods. Responses need a cache-control header with `max-age`/`s-maxage` ≥ 1s, `public` (not `private`/`no-cache`/`no-store`), and a 2xx status — otherwise storage errors. For responses you don't control, rewrite headers with `fetchWithCache`.\n\n**Limits per invocation:** 100 lookups, 20 insertions/deletions. Exceeding: further lookups return nothing; 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), auto-invalidated on redeploy and on `max-age`/`s-maxage` expiry.\n\n## `@netlify/cache` module\n\nInstall to get helpers, time constants (`MINUTE`/`HOUR`/`DAY`), and a `caches` export for local dev:\n\n```\nnpm install @netlify/cache\n```\n\n**Local-dev workaround:** the `caches` global isn't part of Node.js. Netlify provides it in its Functions/Edge runtimes (live and under `netlify dev`), but if you run your framework's own dev server the global is undefined and throws — import it instead:\n\n```ts\nimport { caches } from \"@netlify/cache\";\nconst cache = await caches.open(\"my-cache\");\n```\n\nRequires Netlify CLI 20.0.3+; nothing persists locally (lookups return nothing, writes/deletes don't mutate). No functional change from the global.\n\n### `cacheHeaders(settings)` → header object\n\n```ts\nimport { cacheHeaders, DAY } from \"@netlify/cache\";\n\nconst headers = {\n  \"x-custom-header\": \"some value\",\n  ...cacheHeaders({\n    ttl: 2 * DAY,          // s-maxage\n    swr: HOUR,             // stale-while-revalidate\n    durable: true,\n    tags: [\"product\", \"sale\"],\n    overrideDeployRevalidation: [\"tag\"], // opt out of atomic-deploy invalidation\n    vary: {\n      cookie: [\"ab_test_name\", \"ab_test_bucket\"],\n      query: [\"item_id\", \"page\"], // or true for all\n      country: [\"us\", [\"es\", \"pt\"]], // nested = OR\n      language: [\"en\"],\n      header: [\"Device-Type\"],\n    },\n  }),\n};\n```\n\nFor only generic (non-Netlify) headers, use the `cdn-cache-control` npm module instead.\n\n### `fetchWithCache(resource, options?, cacheSettings?)`\n\nDrop-in `fetch` that returns a cached response or fetches, stores, and returns. `cacheSettings` override conflicting response headers; with `swr`, background revalidation is handled automatically.\n\n```ts\nimport { fetchWithCache, DAY } from \"@netlify/cache\";\n\nconst response = await fetchWithCache(\"https://example.com/expensive-api\", {\n  ttl: 2 * DAY,\n  tags: [\"product\", \"sale\"],\n  vary: { cookie: [\"ab_test_name\"], query: [\"item_id\", \"page\"] },\n});\n```\n\n### `getCacheStatus(response | headers | headerString)`\n\nReturns `{ hit, caches: { durable: { hit, stale, stored, ttl }, edge: { hit, stale } } }`.\n\n```ts\nconst { hit, edge, durable } = getCacheStatus(response);\n```\n\n### `needsRevalidation(response)` → boolean\n\nOnly needed when calling `cache.match`/`cache.put` directly (not with `fetchWithCache`+`swr`). True when a Cache-API response is stale within its SWR window — return it, then revalidate in `context.waitUntil` and `cache.put` the fresh copy:\n\n```ts\nif (cached) {\n  if (needsRevalidation(cached)) {\n    context.waitUntil(\n      fetch(request).then((fresh) => {\n        const response = new Response(fresh.body, {\n          headers: { ...Object.fromEntries(fresh.headers), ...cacheHeaders({ ttl: MINUTE, swr: HOUR }) },\n        });\n        return cache.put(request, response);\n      })\n    );\n  }\n  return cached;\n}\n```\n\n## Durable cache\n\nAdd `durable` (serverless only) so edge nodes lacking a local copy check the shared durable cache before invoking the function — fewer invocations, better cache-miss latency. Eventually consistent, so multiple regions may still invoke the function a few times per version. Co-located with the site's functions region. Works with `Netlify-Vary`, SWR, and on-demand invalidation. **Next.js:** Next Runtime 5.5.0+ uses the durable cache automatically.\n\n## Debugging with `Cache-Status`\n\nNetlify sets `Cache-Status` (RFC 9211) on all responses. Check it on a **deployed** URL. Look for values starting `\"Netlify Edge\"` or `\"Netlify Durable\"`:\n\n- `\"Netlify Edge\"; fwd=miss` — nothing cached.\n- `\"Netlify Edge\"; hit` — served from cache.\n- `\"Netlify Edge\"; hit; fwd=stale` — stale served while revalidating (SWR).\n- Durable stored on miss: `\"Netlify Durable\"; fwd=uri-miss; stored=true; ttl=3600`.\n- Durable hit: `\"Netlify Durable\"; hit; ttl=1234`.\n\n`ttl` negative = 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.\n\n<!-- Gaps: package/method inconsistency in @netlify/cache local-dev docs (caches import shown with cache.set, not the documented cache.put) resolved to cache.put per Cache API surface. -->\n\n<!-- system: agent-context/caching/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->\n# Netlify house rules (caching)\n\nThese are org conventions, not docs facts — merged into the rendered skill by\nctx-gen and never generated. Owned by the skills maintainer.\n\n1. Only `GET` responses are cached by the CDN. `POST`/`PUT`/etc. are never\n   cached regardless of headers — expose cacheable data on a `GET` route\n   (put the inputs in the URL or query string).\n2. Without `Netlify-Vary: query=...`, the full query string is the cache key —\n   every distinct query string (`utm_*`, `fbclid`, ...) is a separate cache\n   entry. Enumerate only the params that actually change the response.\n3. `netlify dev` does not emulate the CDN cache — a cache miss every time\n   locally is expected, not a bug. Verify caching behavior on a deployed URL\n   (Deploy Preview or production) via its `Cache-Status` header.\n4. `purgeCache()` has ambient credentials only inside a deployed function.\n   From CI, local scripts, or the build, pass `token` (a personal access\n   token read from an env var, never hardcoded) and `siteID`.\n"}],"summary":"Fields changed: 3. /description, /included_files, /skill_md_contents.","summary_kind":"deterministic","summary_metadata":{}}