Caching Functions

defineCachedFunction wraps any async function with caching. One wrapper gives you TTL, stale-while-revalidate (SWR), integrity checks, and deduplication of concurrent in-flight calls. cachedFunction is an alias.

import { defineCachedFunction } from "ocache";

const getRepo = defineCachedFunction(
  async (name: string) => {
    const res = await fetch(`https://api.github.com/repos/${name}`);
    return res.json();
  },
  {
    name: "github-repo",
    maxAge: 60, // each result stays fresh for 60 seconds
  },
);

// First call fetches; calls within the next 60s return the cached value.
const repo = await getRepo("unjs/ocache");

#How a call is served

Every call resolves to one of four outcomes. The status field on the entry reports which one happened:

StatusWhat happened
"miss"Nothing was cached. The function ran, and the caller waited for it.
"hit"A fresh cached value was returned. The function did not run.
"stale"An expired value was served instantly, and a background refresh started (SWR).
"revalidated"An expired value was refreshed in the foreground before the call returned.

Three mechanisms sit behind this:

  • TTL — a value counts as fresh for maxAge seconds. After that, it must be resolved again.
  • Deduplication — concurrent calls for the same key share a single in-flight resolution. A burst of requests runs the function once.
  • Integrity — each entry is bound to a hash of the function and its options. Change the function body or its options, and old entries are silently ignored instead of served.

Important

maxAge defaults to 1 second. SWR is off by default: without swr: true, an expired entry is resolved in the foreground and the caller waits. Set both explicitly for meaningful caching.

#Options

const cached = defineCachedFunction(fn, {
  // Identity & keys
  name: "my-fn", // cache key name (defaults to fn.name, then a source hash)
  group: "functions", // key group (default: "functions")
  base: "/cache", // key prefix — string, or string[] for multi-tier caching
  getKey: (...args) => args[0], // custom key derived from the arguments
  integrity: "v2", // custom integrity value (defaults to a hash of fn + options)

  // Lifetimes (all in seconds)
  maxAge: 60, // fresh lifetime (default: 1)
  swr: false, // serve stale while revalidating in the background (default: false)
  staleMaxAge: 300, // how long a stale value may still be served (SWR only)
  getMaxAge: (entry) => entry.value?.expires_in, // per-entry lifetime from the value
  maxResolveTime: 30, // deadline for one shared resolution (default: 30)

  // Storage
  storage, // a StorageInterface, or a factory returning one

  // Behavior hooks
  shouldBypassCache: (...args) => false, // true → skip the cache, call fn directly
  shouldInvalidateCache: (...args) => false, // true → force a fresh resolution
  validate: (entry) => entry.value !== undefined, // false → re-resolve this entry
  serialize: (entry) => entry.value, // prepare the value for storage
  transform: (entry) => entry.value, // reshape the value before returning
  onError: (error) => console.error(error), // cache read/write/SWR/hook errors
  waitUntil: (promise) => ctx.waitUntil(promise), // hand background work to the host
});

Entries live in an in-memory store that is private to this cached function unless you pass storage. See Storage for backends and sharing, and Caching HTTP Handlers for full response caching.

#Lifetimes

#maxAge

The fresh lifetime in seconds. While an entry is fresh, calls return it without running the function.

maxAge: 0 — or a getMaxAge result of <= 0 — disables caching: such an entry would expire the moment it was written, so ocache resolves on every access and stores nothing. Negative values become 0; they never mean "cache forever".

Note

Only an explicit 0 opts out. An omitted option or an explicit undefined falls back to the default (maxAge: 1). A config assembled from optional values, such as { maxAge: rule.maxAge }, therefore cannot silently turn caching off.

#swr and staleMaxAge

With swr: true, an expired entry is served immediately while a refresh runs in the background. staleMaxAge bounds that stale window:

  • Set — the stale value may be served for up to staleMaxAge seconds past expiry. After maxAge + staleMaxAge the entry is fully expired and removed from storage (that sum is its storage TTL). The next call resolves in the foreground.
  • Unset — the stale window has no time limit. The last successful value is served for as long as the entry exists, and no storage TTL is written; the entry stays until the backend evicts it. This is the ISR pattern. The built-in memory storage evicts above 10 000 entries or 100 MB.
  • 0 — stale values are never served. Even with SWR on, an expired entry is revalidated in the foreground.

Important

Never write an entry with neither an expiry nor a storage TTL by accident: with swr: true and no staleMaxAge, cache growth is bounded by your backend's capacity, not by time.

#Background work

A cached call starts work that outlives it: the storage write of a miss, an SWR refresh, and the eviction that follows a failed resolution. On a long-running server these simply finish. On a serverless runtime the instance may be frozen as soon as the call returns, and the platform needs to be told to keep it alive.

waitUntil receives every such promise:

const getStats = cachedFunction(fetchStats, {
  maxAge: 60,
  swr: true,
  staleMaxAge: 3600,
  waitUntil: (promise) => ctx.waitUntil(promise),
});

Without it, an SWR refresh on a serverless runtime can be frozen before its write lands, so the next request finds the same stale entry and refreshes again.

Cached handlers read the srvx-compatible event.req.waitUntil from the request when the adapter provides one, so they need this option only to override that. When both are present, the option wins — one promise gets one owner, and a host that also drains its own background queue never counts the same work twice.

The hook is excluded from the integrity hash, like storage and the key options: it decides who owns background work, not what the function computes, so adding it — or moving between runtimes — keeps the entries you already stored.

#Cache keys

Every entry is stored under a key built from base, group, and name, followed by a per-call component derived from the arguments — either your getKey result or a hash of all arguments.

#The name

name resolves as opts.name || fn.name || anon_<hash(fn)>:

  • A named function (or one assigned to a named binding) gets a stable, human-readable key.
  • An anonymous inline function falls back to a hash of its source, so two different inline functions cannot collide.

ocache escapes the resolved name and group before adding them to the key: characters outside [A-Za-z0-9_] are removed, and if that changed the value, a hash of the raw value is appended. A name containing : or a space therefore cannot be misread as key structure (fn.bind(null) produces the name bound fn, for example). Your getKey value is the terminal key segment and is stored exactly as you return it — it decides identity only within its own function.

Important

A source hash cannot distinguish two functions with identical source but different closed-over variables — the typical output of a factory or a loop. They share a key and keep replacing each other's entries. Give each an explicit name or getKey. The same applies to bound functions, whose source is always function () { [native code] }: fn.bind(null, 1) and fn.bind(null, 2) hash alike, so give each an explicit integrity when they share a name.

The standalone resolveCacheKeys / invalidateCache / expireCache helpers never see fn. When you purge entries with them, always pass the same name you cached under.

#The argument hash

Without getKey, ocache hashes all arguments. The hash reads anything it can read synchronously: plain objects, arrays, Map, Set, Date, URL, URLSearchParams, Headers, typed arrays, and any class that provides toJSON or keeps its state in own enumerable properties.

Two limits apply:

  • Opaque values — a Blob, ReadableStream, Promise, Request, or WeakMap has asynchronous or hidden contents. Such an argument contributes only its type to the key, so two different ones share an entry. Pass a getKey that derives the key from something readable when such an argument decides the result.
  • Nesting depth — the hash reads at most 128 levels. A deeper argument throws a RangeError before the function runs. Arguments often come from parsed request bodies, and a few kilobytes of JSON can nest thousands of levels, so this is a fixed refusal rather than a stack overflow whose depth moves between runtimes. Pass a getKey that reads only the fields the result depends on.
// Custom key derived from a single id argument
const getUser = defineCachedFunction(async (id: string) => db.users.find(id), {
  name: "getUser",
  maxAge: 60,
  getKey: (id) => id,
});

await getUser("user-123"); // key includes "user-123"

#Dynamic TTL

Some values carry their own expiry — an OAuth token with expires_in, an upstream response with max-age. getMaxAge derives the lifetime from the resolved value instead of a static option.

It runs after the resolver and returns either a number of seconds (shorthand for maxAge) or { maxAge?, staleMaxAge? } to also override the stale window. Returned fields override the static options for that entry — for the freshness check, the storage TTL, and (for handlers) the advertised Cache-Control.

const getToken = defineCachedFunction(
  async () => {
    const res = await fetch("https://auth.example.com/token", { method: "POST" });
    return res.json(); // { access_token, expires_in }
  },
  {
    name: "oauth-token",
    // Cache each token for its own lifetime, minus a small safety margin
    getMaxAge: (entry) => Math.max(1, (entry.value?.expires_in ?? 60) - 5),
  },
);

Note

Return undefined, or omit a field, to keep the static option for that field. A returned maxAge <= 0 disables caching for that entry; negative values become 0.

#Resolution deadline

Concurrent calls for one key share a single resolution, so a resolver that never settles would block that key forever: every later call would join a resolution that never finishes, and one wedged upstream could make the key unavailable for the life of the process.

maxResolveTime sets a deadline in seconds (default: 30). It covers the complete shared resolution — the resolver plus the getMaxAge and serialize hooks, including reading a response body that never ends.

const getReport = defineCachedFunction(fetchFromUpstream, {
  name: "report",
  maxAge: 300,
  maxResolveTime: 5, // give up on a resolution after 5s
});

At the deadline, every waiting caller rejects with a TimeoutError, the key becomes free, and the next call starts a new resolution:

try {
  await getReport();
} catch (error) {
  if ((error as Error).name === "TimeoutError") {
    // upstream is wedged — serve a fallback
  }
}

Behavior to know:

  • A timed-out resolution is a failed resolution, like a resolver that throws. The entry it was refreshing is evicted, so SWR does not keep serving a value whose refresh is broken. With swr: true, the deadline also bounds the background refresh; its timeout is reported through onError because the triggering call has already returned.
  • A cached function receives no cancellation signal. Its abandoned resolver keeps running and may still settle, but no caller waits for it, its value is never served, and it cannot overwrite a value a later call cached. A cached handler does get a signal — see the handler guide.
  • Tuning — fractions work (maxResolveTime: 0.5 is half a second). Pass Infinity or 0 to disable the deadline. Raise it for resolvers that are legitimately slow: a deadline can fail a resolution that would have completed just after the limit.

#Custom serialization

Some resolver outputs cannot be stored as-is — a ReadableStream, a class instance. Use serialize to convert the value into a storable form and transform to rebuild the usable value on the way out.

serialize runs once per resolution, after the resolver (and getMaxAge). Deduplicated concurrent calls share that one run, so it may safely consume a one-use source such as a stream:

const getReport = defineCachedFunction(
  () => generateReportStream(), // resolves a one-shot ReadableStream
  {
    name: "report",
    maxAge: 300,
    serialize: (entry) => streamToString(entry.value), // store as a string
    transform: (entry) => stringToStream(entry.value), // rebuild a stream on read
  },
);

#Binary values

A resolver that returns bytes — a Uint8Array, an ArrayBuffer, or any typed array or DataView — is handled without a hook. ocache stores it in the form your backend can return, and every call gets a Uint8Array back:

const getThumbnail = defineCachedFunction(
  async (id: string) => new Uint8Array(await renderThumbnail(id)),
  { name: "thumbnail", maxAge: 3600, getKey: (id) => id },
);

const bytes = await getThumbnail("photo-1"); // Uint8Array, on a miss and on a hit alike

What the entry holds depends on one thing: whether your backend declares binary.

  • A backend that declares it (memory storage, or any store that holds values by reference) keeps the Uint8Array itself. Nothing is encoded on the way in or decoded on the way out — and every hit receives the same array, so treat a returned buffer as read-only. Copy it (bytes.slice()) before writing into it.
  • Any other backend — Redis, a filesystem, KV, anything that serializes entries — stores the value as base64 text, which survives a JSON round trip. It is decoded back to bytes on read.

Three details are worth knowing:

  • The value comes back as a Uint8Array, whatever byte type went in. An ArrayBuffer, Int8Array, or DataView is a view over the same bytes on the way in, and a Uint8Array over those bytes on the way out. A miss and a hit therefore never return different types.
  • Only a value that is bytes takes this path. A byte view nested in an object ({ image, meta }) is stored as an ordinary property, which a serializing backend turns into {"0":255,…}. Use serialize/transform for those shapes.
  • A backend that declares binary but does not keep views returns a mangled value. ocache treats that read as a miss and resolves again, rather than handing you an object where your bytes were. Every call pays for the resolver, so only declare binary if it is true.

#Cache status

transform receives the entry with a status field — "miss", "hit", "stale", or "revalidated" — reporting how this call was served. Use it for metrics or observability. The field exists only during the call; it is not stored.

const getData = defineCachedFunction(fetchData, {
  name: "data",
  maxAge: 60,
  transform: (entry) => {
    metrics.increment(`cache.${entry.status}`); // "hit" / "miss" / ...
    return entry.value;
  },
});

#Error handling

onError receives errors that ocache handles for you instead of throwing: storage read/write failures, hook errors, and background SWR refresh failures (including their timeouts). Errors from the resolver itself during a foreground call still reject that call as usual.