Cache

Nitro provides a caching system built on top of the storage layer, powered by ocache.

Caching lets you store the result of expensive operations (rendered responses, upstream API calls, heavy computations) and serve it again without redoing the work. Nitro offers three ways in:

  • Cached handlers: cache the full response of an event handler.
  • Cached functions: cache the result of any function and reuse it across handlers.
  • Route rules: enable caching for route patterns from your config, without touching handler code.

#Cached handlers

To cache an event handler, use the defineCachedHandler method.

It works like defineHandler but with a second parameter for the cache options.

routes/cached.ts
import { defineCachedHandler } from "nitro/cache";

export default defineCachedHandler((event) => {
  return "I am cached for an hour";
}, { maxAge: 60 * 60 });

With this example, the response will be cached for 1 hour. Once the cache expires, the next request waits for the handler to resolve a fresh value before responding. If you prefer to immediately serve the stale response while it is revalidated in the background, set swr: true (see SWR behavior).

See the options section for more details about the available options.

Important

maxAge defaults to 1 second. Always set it explicitly for anything you actually want cached.

#What the handler can see

For cacheable requests, a handler receives exactly the request data that the cache key covers. Everything else is removed before the handler runs, so a handler cannot render output from a value the key does not distinguish and have it stored under a shared key:

  • Request headers are stripped unless listed in varies. This includes if-none-match / if-modified-since (Nitro answers conditional requests itself) and tracing headers such as traceparent or x-request-id.
  • Query parameters are stripped unless listed in allowQuery. By default /page, /page?a=1 and /page?utm=x all share one entry, and url.searchParams is empty inside the handler.
  • Cookies are stripped unless listed in allowCookies, and set-cookie is removed from every cached response.
  • authorization / proxy-authorization are stripped unless allowAuthorization is set.
  • host is the one header that is rewritten rather than removed: it carries the host of the resolved request origin, which is part of the key. A handler serving several hostnames therefore gets one entry per host.

Use shouldBypassCache for requests that must reach the handler untouched. A bypassed request keeps its headers, cookies, query string and body, and its response is returned without being stored.

Read more in ocache.unjs.io/docs/handler#headers-the-handler-cant-see.

#Automatic HTTP headers

When using defineCachedHandler, Nitro automatically manages HTTP cache headers on cached responses:

  • etag: a weak ETag (W/"...") is generated from the response body hash if not already set by the handler.
  • cache-control: synthesized from the lifetime actually enforced for the entry, unless the handler already set one (see the table below).
  • vary: every name in varies is merged into the response Vary, plus Cookie when allowCookies is used. allowQuery adds nothing, because query parameters already live in the URL.
  • x-cache: a CDN-style cache status header (HIT, STALE, REVALIDATED or MISS). Customizable with the cacheStatusHeader option.
OptionsSynthesized cache-control
{ maxAge: 60 }max-age=60
{ maxAge: 60, swr: true }max-age=60, s-maxage=60
{ maxAge: 60, swr: true, staleMaxAge: 600 }max-age=60, s-maxage=60, stale-while-revalidate=600

stale-while-revalidate is only advertised when staleMaxAge is set, since the directive requires a number of seconds. With swr: true and no staleMaxAge the stale window is unlimited, so downstream caches simply revalidate after max-age while Nitro answers from its stale copy.

To keep caching server-side only without advertising cache-control to clients and CDNs, set sendCacheControl: false.

Note

last-modified is never generated. The time an entry was filled is not the time the content changed, so set the header yourself when the handler knows the real modification time. A handler-set cache-control is likewise never overwritten.

#Conditional requests (304 Not Modified)

Cached handlers automatically support conditional requests. When a client sends if-none-match matching the cached etag, or if-modified-since at or after a last-modified the handler set, Nitro returns a 304 Not Modified response without a body. if-none-match wins when both are present.

The request validators are captured before the request is narrowed, so the handler never sees them and cannot do its own conditional handling.

#Cacheable requests

Only GET and HEAD requests are cached. All other HTTP methods (POST, PUT, DELETE, etc.) automatically bypass the cache and call the handler directly, as do requests carrying a Range header.

GET and HEAD are cached under separate entries, since a HEAD response carries no body. The method stays part of the key even with a custom getKey.

#Request deduplication

When multiple concurrent requests hit the same cache key while the cache is being resolved, only one invocation of the handler runs. All concurrent requests wait for and share the same result.

A shared resolution is bounded by maxResolveTime (30 seconds by default). On timeout every waiter rejects with a TimeoutError, the entry being refreshed is evicted, and the key is free again. Handlers additionally receive that deadline as event.req.signal, so you can forward it to upstream fetch calls.

#Cached functions

You can also cache a function using defineCachedFunction. This is useful for caching the result of a function that is not an event handler, but is part of one, and reusing it in multiple handlers.

For example, you might want to cache the result of an API call for one hour:

routes/api/stars/[...repo\
import { defineCachedFunction } from "nitro/cache";
import { defineHandler, type H3Event } from "nitro";

export default defineHandler(async (event) => {
  const { repo } = event.context.params;
  const stars = await cachedGHStars(repo).catch(() => 0)

  return { repo, stars }
});

const cachedGHStars = defineCachedFunction(async (repo: string) => {
  const data = await fetch(`https://api.github.com/repos/${repo}`).then(res => res.json());

  return data.stargazers_count;
}, {
  maxAge: 60 * 60,
  name: "ghStars",
  getKey: (repo: string) => repo
});

The stars will be cached using the cache storage (in-memory by default) under a cache key derived from the group, name and getKey result, with value being the number of stars.

{"expires":1677851092249,"value":43991,"mtime":1677847492540,"integrity":"ZUHcsxCWEH"}

Important

Because the cached data is serialized to JSON, it is important that the cached function does not return anything that cannot be serialized, such as Symbols, Maps, Sets...

A value that is bytes (a Uint8Array, ArrayBuffer, or any typed array) is handled without a hook and always comes back as a Uint8Array. How it is stored depends on the backend: one that declares binary keeps the bytes as-is, while a serializing backend (including Nitro's default cache storage) stores base64. Bytes nested inside an object never take this path, use serialize/transform for those shapes.

Note

If you are using edge workers to host your application, you should follow the instructions below.

In edge workers, the instance is destroyed after each request. Nitro automatically uses event.waitUntil to keep the instance alive while the cache is being updated while the response is sent to the client.

To ensure that your cached functions work as expected in edge workers, you should always pass the event as the first argument to the function using defineCachedFunction.

routes/api/stars/[...repo\
import { defineCachedFunction } from "nitro/cache";
import { defineHandler, type H3Event } from "nitro";

export default defineHandler(async (event) => {
  const { repo } = event.context.params;
  const stars = await cachedGHStars(event, repo).catch(() => 0)

  return { repo, stars }
});

const cachedGHStars = defineCachedFunction(async (event: H3Event, repo: string) => {
  const data = await fetch(`https://api.github.com/repos/${repo}`).then(res => res.json());

  return data.stargazers_count;
}, {
  maxAge: 60 * 60,
  name: "ghStars",
  getKey: (event: H3Event, repo: string) => repo
});

This way, the function will be able to keep the instance alive while the cache is being updated without slowing down the response to the client.

On platforms where the request carries no waitUntil, pass the platform hook through the waitUntil option instead.

#Options

The defineCachedHandler and defineCachedFunction functions accept the following options:

#Shared options

These options are available for both defineCachedHandler and defineCachedFunction:

Storage base (prefix) used for cache keys, usually matching a storage mount point. :br Defaults to /cache (stored under the cache: prefix). When an array is provided, reads try each base in order and writes go to all of them (multi-tier caching).

Guessed from the function name if not provided, and falls back to anon_<hash> (computed from the function code) otherwise.

Defaults to 'nitro/handlers' for handlers and 'nitro/functions' for functions.

A function that accepts the same arguments as the original function and returns a cache key (String). :br If not provided, a built-in hash function will be used to generate a key based on the function arguments. For cached handlers, the key is derived from the request origin, path and method. :br :br A custom getKey replaces the generated key but not the request narrowing: allowQuery, allowCookies and varies still decide what the handler sees.

A value that invalidates the cache when changed. :br By default, it is computed from function code and options, so changing either silently ignores older entries instead of serving them.

Maximum age that cache is valid, in seconds. :br Defaults to 1 (second). An explicit 0 disables caching for that handler or function; an omitted or undefined value falls back to the default.

Maximum number of seconds a stale value can still be served after maxAge expires, while revalidation happens in the background. Only applies when swr is enabled. :br When set to 0, stale values are never served (equivalent to disabling swr). When unset, stale values can be served without a time limit and no storage TTL is written, so entries live until the backend evicts them.

Enable stale-while-revalidate behavior to serve a stale cached value while asynchronously revalidating it. :br When enabled, expired cached values are returned immediately while revalidation happens in the background. When disabled, the caller waits while the value is revalidated in the foreground. :br Defaults to false. See SWR behavior.

Derive the cache lifetime per entry from the resolved value, overriding the static maxAge / staleMaxAge options for that entry. Return a number of seconds (shorthand for maxAge) or an object to also override staleMaxAge. :br Useful for values that carry their own expiry, such as access tokens: getMaxAge: (entry) => entry.value.expires_in. A resolved value <= 0 disables caching for that entry. For handlers, entry.value is the Response and the synthesized cache-control follows the returned lifetime.

Deadline for one shared resolution, in seconds. Defaults to 30. :br On timeout every waiting caller rejects with a TimeoutError and the entry being refreshed is evicted. Set Infinity or 0 to disable it, and raise it for legitimately slow resolvers.

Override the backend used for this handler or function. :br Defaults to Nitro's cache storage, an adapter over the cache: prefix of the Nitro storage layer.

Hand background work (cache writes, SWR refreshes, evictions) to the host runtime. :br Takes precedence over event.req.waitUntil. Only needed on serverless platforms where the request does not carry one.

A function that returns a boolean to invalidate the current cache and create a new one.

A function that returns a boolean to bypass the current cache without invalidating the existing entry. :br For handlers, a bypassed request reaches the handler unchanged (credentials, cookies and full query string included) and its response is neither stored nor given cache headers.

A custom error handler called when the cached function throws. :br By default, errors are logged to the console and captured by the Nitro error handler.

#Handler-only options

These options are only available for defineCachedHandler:

When true, skip full response caching and only handle conditional request headers (if-none-match, if-modified-since) for 304 Not Modified responses. The handler is called on every request and its own etag / last-modified are the conditions, so it must set them itself. No x-cache header is emitted in this mode.

An array of request header names to vary the cache key on. Headers listed here are preserved on the request during cache resolution and included in the cache key, making the cache unique per combination of header values. They are also merged into the response's Vary header. :br :br Headers not listed in varies are stripped from the request before calling the handler to ensure consistent cache hits. :br :br A response whose own Vary names a header outside the key is returned but not stored, so keep the two lists in sync. :br :br Multi-tenant setups no longer need ['host'] here: the request origin is already part of the generated key. Add x-forwarded-host only if your handler reads it behind a proxy.

Allowlist of query parameter names that vary the cache key. By default no query parameter varies the key or reaches the handler, so /page?a=1 and /page?utm=x share one entry and url.searchParams is empty inside the handler. :br :br Set an array to opt specific names back in (case-sensitive, order-independent), or true to key on the full query string, which is appropriate for proxies and search endpoints where the parameters are not known ahead of time.

Allowlist of cookie names that participate in caching. Listed cookies vary the cache key and are kept in the cookie header the handler sees. :br :br By default, no cookies are allowed: the cookie request header is stripped before the handler runs, and any set-cookie header is dropped from the response before it is cached or returned, so a per-user cookie (such as a session id) can never leak to another user through the cache. Only allowlist cookies whose values are safe to share across every user hitting the same cache key (e.g. a theme or locale preference), never per-user secrets. :br :br Setting this emits Vary: Cookie, which can collapse hit rates in CDNs and shared proxies. Consider moving the choice into the URL with allowQuery instead, or pairing it with sendCacheControl: false.

Let authorization and proxy-authorization reach the handler and vary the cache key. Defaults to false, which strips both from cacheable requests. :br :br Every client sending the same credential value shares one entry. When a response must never be shared, use shouldBypassCache instead.

Whether to automatically set a cache-control response header. Defaults to true. :br Set to false for server-only caching: responses are still stored and served from cache, but no cache-control header is advertised to clients and CDNs. This does not emit no-store, so downstream caches may still store the response under their own heuristics.

Add a cache status response header (X-Cache: HIT | STALE | REVALIDATED | MISS). Defaults to true. Pass a string to use a custom header name, or false to disable.

Stream the response that fills a cache entry instead of buffering it first. Defaults to false. :br Improves time to first byte for streaming renders, at the cost of a synthesized etag (the digest needs a finished body) and mid-body error recovery. Nothing partial is ever stored, and later requests are served the stored entry as usual.

Largest response body, in bytes, that may be buffered for storage. A larger response streams through uncached, exactly as a bypassed request does. :br Nitro's default storage declares no per-entry ceiling, so there is no default limit. Set this on any route that proxies upstream responses whose size you do not control.

Additional predicate deciding whether a response is cacheable, running on top of the built-in checks (which always apply and can only be narrowed). Return false to skip caching a response; it is still returned to the caller, just not stored. :br It runs on reads as well as writes, may be async, and fails closed: a throwing hook counts as "not cacheable" and reaches onError.

#Function-only options

These options are only available for defineCachedFunction:

Transform the cache entry before returning. The return value replaces the cached value. :br The entry also carries a per-call entry.status ("hit", "stale", "revalidated" or "miss") describing how the value was served, useful for metrics or conditional logic.

Prepare the resolved value for storage, right before the entry is persisted (the write-side counterpart of transform). Useful when the function returns something that cannot be stored as-is (e.g. a stream or a class instance): serialize converts it on write and transform restores it on read. :br It runs once per resolution and is shared by deduplicated callers, so it may safely consume a one-use source such as a stream.

Validate a cache entry. Return false (or a Promise resolving to false) to treat the entry as invalid and trigger re-resolution. The second argument carries the args of the current call, so the entry can be validated against it.

#SWR behavior

The stale-while-revalidate (SWR) pattern is opt-in via the swr option (disabled by default). Understanding how it interacts with other options:

swrmaxAgeBehavior
false (default)3600Cache for 1 hour, wait for the fresh value when expired
true3600Cache for 1 hour, serve stale while revalidating
true3600 with staleMaxAge: 600Cache for 1 hour, serve stale for up to 10 more minutes while revalidating
true3600 with staleMaxAge: 0Cache for 1 hour, never serve stale (same as swr: false)

When swr is enabled and a cached value exists but has expired:

The stale cached value is returned immediately to the client.
The function/handler is called in the background to refresh the cache.
On edge workers, event.waitUntil is used to keep the background refresh alive.

When swr is disabled (default) and a cached value has expired:

The client waits while the function/handler resolves a fresh value.
The entry is replaced before the call returns (a REVALIDATED status).

Tip

Enable swr when slightly outdated data is acceptable (content pages, listings, mirrored third-party APIs) so response times stay flat even when the cache expires. Keep it disabled when callers must never receive stale data, such as access tokens or per-request authorization checks.

Warning

With swr: true and no staleMaxAge, entries carry no storage TTL: the last good value is served until the backend evicts it. That is exactly the ISR pattern, but it means cache growth is bounded by your backend's capacity, not by time. Set staleMaxAge for eventual cleanup.

#Cache keys

When using the defineCachedFunction or defineCachedHandler functions, the cache key is generated using the following pattern:

`${options.base}:${options.group}:${options.name}:${options.getKey(...args)}.json`

group and name are escaped before they enter the key: characters outside [A-Za-z0-9_] are removed and, when that changed the value, a hash of the raw value is appended. That is why Nitro's nitro/functions group appears as nitrofunctions.<hash> and cannot be confused with the : key structure. The getKey result is the terminal segment and is stored exactly as returned.

For example, the following function:

import { defineCachedFunction } from "nitro/cache";

const getAccessToken = defineCachedFunction(() => {
  return String(Date.now())
}, {
  maxAge: 10,
  name: "getAccessToken",
  getKey: () => "default"
});

Will be stored under a key like:

cache:nitrofunctions.3aFYDY_G88ZLdElv-hEB0r8snSViMYJ1NbRtlNP9o_0:getAccessToken:default.json

The default base is /cache, which the storage layer normalizes to the cache: prefix (as it does with any other / in the key).

Note

For cached handlers, the generated key covers the request origin (scheme, host and port), the URL path, and the method, plus hashes of the values behind varies, allowQuery and allowCookies. GET and HEAD are stored separately.

Important

The name falls back to a hash of the function source, which cannot tell apart handlers or functions produced by a factory or a loop: they have identical source but different closed-over variables, so they share a key and overwrite each other's entries. Always pass an explicit name (or getKey) for those.

Note

Without getKey, the arguments are hashed. Opaque values (Blob, ReadableStream, Promise, Request, WeakMap) contribute only their type, so two different ones share an entry, and arguments nested deeper than 128 levels throw a RangeError. Pass a getKey reading only the fields the result depends on when either applies.

#What gets cached

Before a response is stored, the following checks apply. A response that fails any of them is still returned to the caller, it is just never stored or served from the cache:

CheckReason
Status is not 200, 203, 301 or 308Only these are complete, reusable representations. Errors, 204/304, 206 ranges and temporary redirects are excluded.
cache-control opt-out from the handlerno-store, private, no-cache, or a zero shared lifetime (s-maxage when present, else max-age).
Vary: *The strongest "do not share" signal short of no-store.
A Vary naming a header outside the cache keyStoring it would replay one variant to every client. Add the header to varies instead.
A missing bodyNothing to replay. An empty (zero-byte) 200 is fine and caches normally.
An etag or last-modified equal to the literal string "undefined"The usual result of stringifying a missing value; it would break conditional requests.

These checks always apply. Use shouldCache to narrow further. A response rejected by a built-in check also gets no synthesized cache-control, so a CDN in front cannot hold on to an error; a response rejected by your shouldCache still does.

Note

cache-control: must-revalidate is not an opt-out. The response is stored and served while fresh, but never while stale, so SWR is disabled for that response only.

Read more in ocache.unjs.io/docs/cache-control.

#Cache invalidation

Cached entries can be invalidated programmatically at runtime (for example from a webhook when the underlying data changes) without waiting for maxAge to expire.

#.invalidate() and .expire() methods

Every function created with defineCachedFunction exposes on-demand revalidation methods:

  • .invalidate(...args): removes the cached entry entirely. The next call re-invokes the function and waits for the fresh value.
  • .expire(...args): marks the cached entry as stale without removing it. With swr enabled, the stale value is still served while the next call triggers a background refresh; without SWR, the next call re-resolves before returning.
  • .resolveKeys(...args): resolves the storage key(s) the entry is cached under (one per base prefix).

Arguments are passed through getKey to generate the cache key.

import { defineCachedFunction } from "nitro/cache";

const cachedGHStars = defineCachedFunction(async (repo: string) => {
  const data = await fetch(`https://api.github.com/repos/${repo}`).then(res => res.json());
  return data.stargazers_count;
}, {
  maxAge: 60 * 60,
  name: "ghStars",
  getKey: (repo: string) => repo,
});

await cachedGHStars("unjs/nitro"); // populates the cache
await cachedGHStars.expire("unjs/nitro"); // marks the entry as stale
await cachedGHStars.invalidate("unjs/nitro"); // removes the entry
await cachedGHStars("unjs/nitro"); // re-invokes the function

If no cached entry matches the given arguments, .invalidate() and .expire() resolve without error and leave storage unchanged. Both also cancel a resolution already in flight for that key, so a pre-purge value cannot be written after the purge.

Note

Nitro's defineCachedHandler currently returns a plain event handler and does not expose these methods, so a cached route cannot be invalidated on demand. When you need that, move the expensive work into a defineCachedFunction called from a plain defineHandler and invalidate the function instead.

#Cache storage

Cache entries are stored using the storage layer under the cache: prefix (derived from the default base: "/cache" option).

By default, no dedicated mount point is configured for it: entries live in the root storage, which uses an in-memory driver and is not persisted across restarts, in both development and production.

To use a persistent backend, set the cache mount point using the storage option:

nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  storage: {
    cache: {
      driver: 'redis',
      /* redis connector options */
    }
  }
})

In development, you can also overwrite the cache mount point using the devStorage option:

nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  storage: {
    cache: {
      // production cache storage
    },
  },
  devStorage: {
    cache: {
      // development cache storage
    }
  }
})

Note

Entries are serialized as JSON through unstorage, so Nitro's default cache storage does not declare binary. A response body that decodes as UTF-8 (HTML, JSON, plain text) is stored as text either way; only a body that is not valid UTF-8 (images, fonts, PDFs) is base64-encoded, as are byte-valued cached functions. Pass your own storage that declares binary (for example ocache's createBlobStorage over a driver's getItemRaw / setItemRaw) to keep those bytes as bytes and skip the encode, decode and 4/3 expansion. :br :br The default backend also declares no per-entry size ceiling, which means cached handlers buffer any response body unless you set maxBodySize.

Read more in ocache.unjs.io/docs/storage.
Read more in Docs > Storage.

#Using route rules

Route rules let you enable caching for all routes matching a glob pattern, directly from your configuration. This is especially useful for a global cache strategy across a part of your application, without touching handler code.

Cache all the blog routes for 1 hour:

nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  routeRules: {
    "/blog/**": { cache: { maxAge: 60 * 60 } },
  },
});

The cache rule accepts the same handler options documented above (swr, staleMaxAge, varies, allowQuery, allowCookies, allowAuthorization, sendCacheControl, cacheStatusHeader, stream, maxBodySize, headersOnly, base, group, name, integrity, maxResolveTime). Hook options such as shouldCache are only available on defineCachedHandler.

If we want to use a custom cache storage mount point, we can use the base option.

nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  storage: {
    redis: {
      driver: "redis",
      url: "redis://localhost:6379",
    },
  },
  routeRules: {
    "/blog/**": { cache: { maxAge: 60 * 60, base: "redis" } },
  },
});

#Route rules shortcuts

You can use the swr shortcut for enabling stale-while-revalidate caching on route rules. swr: 3600 is shorthand for cache: { swr: true, maxAge: 3600 }. When set to true, SWR is enabled with the default maxAge of 1 second, so prefer passing an explicit number.

nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  routeRules: {
    "/blog/**": { swr: 3600 },
    "/api/**": { swr: 60 },
  },
});

To explicitly disable caching on a route, set cache: false (or swr: false) on a more specific pattern. This also removes a cache rule inherited from a broader pattern:

nitro.config.ts
import { defineConfig } from "nitro";

export default defineConfig({
  routeRules: {
    "/api/**": { swr: 60 },
    "/api/realtime/**": { cache: false },
  },
});

Note

When using route rules, cached handlers use the group 'nitro/route-rules' instead of the default 'nitro/handlers', and the generated name scopes the entry to the matched route handler, the HTTP method, the rule pattern and the matched route.

Warning

That generated scope is unique per process. With a persistent cache storage, a restart or a second worker therefore starts from fresh entries rather than reusing the ones already stored. Set an explicit name on the rule when route-rule cache entries need to be shared across processes.

Read more in Docs > Routing#route Rules.

Nitro  builds full-stack servers that deploy anywhere.