Caching Functions

defineCachedFunction wraps any async function with caching — TTL, stale-while-revalidate (SWR), integrity checks, and automatic deduplication of concurrent in-flight calls.

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, // cache each result for 60 seconds
  },
);

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

cachedFunction is an alias for defineCachedFunction.

How it works

  • TTL — a cached value is served until maxAge seconds pass, then it is re-resolved.
  • Deduplication — concurrent calls for the same key share a single in-flight resolution, so the underlying function runs once even under a burst of requests.
  • Integrity — entries are tied to a hash of the function and its options. Change the function body or options and stale entries are automatically ignored.
maxAge defaults to 1 second. If you want meaningful caching, set it explicitly.
SWR is off by default. Without it, an expired entry is re-resolved in the foreground (the caller waits for the fresh value). Enable swr: true to serve the stale value immediately and refresh in the background — see Dynamic TTL and Invalidation & Expiration.

Options

const cached = defineCachedFunction(fn, {
  name: "my-fn", // cache key name (defaults to fn.name, then a source hash)
  maxAge: 60, // TTL in seconds (default: 1)
  swr: false, // serve stale while revalidating in background (default: false)
  staleMaxAge: 300, // max seconds a stale value may be served (SWR only)
  getMaxAge: (entry) => entry.value?.expires_in, // per-entry TTL from the value
  base: "/cache", // key prefix — string, or string[] for multi-tier caching
  group: "functions", // key group (default: "functions")
  getKey: (...args) => args[0], // custom cache key from the arguments
  shouldBypassCache: (...args) => false, // when true, skip cache and call fn directly
  shouldInvalidateCache: (...args) => false, // when true, force a fresh resolve
  validate: (entry) => entry.value !== undefined, // reject an entry to re-resolve
  serialize: (entry) => entry.value, // prepare the value for storage
  transform: (entry) => entry.value, // reshape the value before returning
  onError: (error) => console.error(error), // handle cache read/write/SWR errors
});

By default, entries are stored in an in-memory store. See Storage to plug in a persistent backend, and HTTP Handlers for caching full HTTP responses.

staleMaxAge

staleMaxAge only takes effect with swr: true. It caps how long a stale value may be served while a background refresh runs; after maxAge + staleMaxAge the entry is fully expired and the next call resolves in the foreground.

staleMaxAge: 0 means a stale value is never served — once expired, the request blocks on revalidation even with SWR on.

Cache keys

Each call resolves to a storage key built from base, group, name, and a key derived from the arguments (getKey, or a hash of all arguments by default).

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

  • A named function (or one passed via 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 don't collide on one key.
A source hash can't tell apart two functions with identical source that differ only by closed-over variables — they will share a key and thrash each other. Pass an explicit name (or getKey) for those.When purging entries with the standalone resolveCacheKeys / invalidateCache / expireCache helpers (which never see fn), always pass the same name you cached with.
// 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 a max-age. Use getMaxAge to derive the lifetime from the resolved value instead of a fixed constant.

It runs after the resolver and returns either a number (seconds, shorthand for maxAge) or { maxAge?, staleMaxAge? } to also override the stale window. The returned values override the static options for that entry, driving both the freshness check and the storage TTL.

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),
  },
);
Return undefined (or omit a field) to fall back to the static option. A returned value <= 0 disables caching for that entry (it re-resolves on every access); negatives are clamped to 0, never treated as "cache forever".

Custom serialization

Some resolver outputs can't be persisted as-is — a ReadableStream, a class instance. Use serialize to convert the value into a storable form on write, and transform to reconstruct the usable value on read.

serialize runs exactly once per resolution, right after the resolver — even across concurrent deduplicated calls — so consuming a one-shot source such as a stream is safe.

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
  },
);

Cache status

transform receives the entry with a status field describing how the value was served on this call — useful for metrics or observability. It is one of:

  • "miss" — resolved fresh (nothing was cached)
  • "hit" — a fresh cached value was returned
  • "stale" — a stale value was served while a background SWR refresh runs
  • "revalidated" — an expired value was re-resolved in the foreground before returning
const getData = defineCachedFunction(fetchData, {
  name: "data",
  maxAge: 60,
  transform: (entry) => {
    metrics.increment(`cache.${entry.status}`); // "hit" / "miss" / ...
    return entry.value;
  },
});