
# 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.

```ts
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`](#cache-status) field on the entry reports which one happened:

| Status          | What 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

```ts
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](/docs/storage) for backends and sharing, and [Caching HTTP Handlers](/docs/handler) 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`](#dynamic-ttl) 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](/docs/isr) pattern. The built-in memory storage evicts above [10 000 entries or 100 MB](/docs/storage#memory-storage--lru).
- **`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:

```ts
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](/docs/handler) 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`](/docs/invalidation) 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.

```ts
// 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`.

```ts
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.

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

```ts
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](/docs/handler#resolution-deadline).
- **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:

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

```ts
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`](/docs/storage#the-storage-interface).

- **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`](#custom-serialization) 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](#how-a-call-is-served). Use it for metrics or observability. The field exists only during the call; it is not stored.

```ts
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.
