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