Skip to content

netlify-caching

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.

3,155
installs
0 up / 0 down, score 0.00
votes
48
repo stars
Updated
less than a month ago
In the catalog since
1 Oct 2026
From
netlify/context-and-tools
Get it through your agent npx skills add netlify/context-and-tools@netlify-caching

Skill instructions

Rendered from the published SKILL.md, 325 lines.

Netlify 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-Control applies to the Netlify CDN only. Reach for this.
  • CDN-Cache-Control applies to all CDNs that support it.
  • Cache-Control applies 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-age is ignored. They change only on a new deploy or manual purge.
  • basic-auth on ANY page disables caching for the ENTIRE site.
  • durable is 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

  • public caches the response. private caches it in the browser only, not in Netlify's shared cache. no-store prevents caching.
  • s-maxage=N sets seconds in Netlify's shared cache (overrides max-age there).
  • max-age=N sets seconds in any cache.
  • stale-while-revalidate=N serves 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|b varies on a subset; bare query varies on all params. Keys are case-sensitive; param order is irrelevant.
  • header=Device-Type|App-Version supports custom and most standard headers.
  • language=en|es+pt uses + for groups. It is checked against Accept-Language with quality weighting.
  • country=us|es+pt uses GeoIP with ISO 3166-1 two-letter codes; + groups.
  • cookie=ab_test|is_logged_in targets specific keys, not the whole Cookie header.

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 over Cache-Tag (passed downstream). Some providers strip Cache-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) => {
Show the whole skill (205 more lines)

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

```sh
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_id or site_slug.
  • Purge by tag: pass cache_tags plus the site.
  • Omitting cache_tags purges the whole site. An empty cache_tags list purges NOTHING.
  • Identifier mapping: in the UI (Project configuration > General > Project details > Project information), Project ID = site_id and 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 a Response from any cache, or undefined.
  • caches.open(name) returns a Cache. 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 a Response or undefined.
  • cache.put(request, response) adds a response.
  • cache.add(request) / cache.addAll(requests) fetch and store.
  • cache.delete(request) returns true.
  • 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-GET methods.
  • 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 (not private/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=miss means nothing was cached.
  • "Netlify Edge"; hit means the response was served from cache.
  • "Netlify Edge"; hit; fwd=stale means a stale response was served while revalidating (SWR).
  • "Netlify Durable"; fwd=uri-miss; stored=true; ttl=3600 means a durable miss that was then stored.
  • "Netlify Durable"; hit; ttl=1234 means 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.

  1. Only GET responses are cached by the CDN. POST/PUT/etc. are never cached regardless of headers — expose cacheable data on a GET route (put the inputs in the URL or query string).
  2. 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.
  3. netlify dev does 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 its Cache-Status header.
  4. purgeCache() has ambient credentials only 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.