---
title: "Cache"
description: "Memoise any call with keys that carry the tenant and region, keep personal responses out of shared caches, and keep serving when the cache store is down."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "http-cache"
canonical: "https://mainsdk.dev/docs/interfaces/cache"
updated: "2026-10-28"
---

# Cache

> Memoise any call with keys that carry the tenant and region, keep personal responses out of shared caches, and keep serving when the cache store is down.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/cache · Updated: 2026-10-28

`cache` makes the app fast without leaking one customer's data to another: keys carry the tenant and region, and credentialed responses are never shared-cacheable.

`cache.wrap` memoises a call under a key that always carries the current tenant and region, runs it once per key under load, and serves a slightly stale value while it refreshes. Every response to a signed-in person or an API key goes out `private, no-store` unless you choose a private policy, and shared CDN caching is a declaration on a read action. Small values you set and read yourself belong in [`kv`](https://mainsdk.dev/docs/interfaces/kv).

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/http-cache.md
- Category: Data

Calls:

- `cache.wrap(key, fn, { ttl, swr?, tags?, scope? })`
- `cache.get(key, { scope? })`
- `cache.set(key, value, { ttl, tags?, scope? })`
- `cache.delete(key | { tag }, { scope? })`
- `new Cache(opts)`
- `parseCache(raw, hasKv?)`
- `cacheIssues(raw)`
- `serveRead(action, req, opts)`
- `etagMatches(header, etag)`
- `weakEtag(output)`

In main.yaml:

```yaml
cache:
  driver: kv
  timeout: 50ms
  max-value: 256KB
  memory: { max-entries: 1000, max-ttl: 30s }
```

Guarantees, whatever the driver:

- Keys carry the tenant and region; no call reads another tenant's entry
- Personal responses go out private, no-store; shared caching only when declared
- A slow or failed store never fails or blocks a request
- One call per key per instance under load


## Drivers

Pick one in main.yaml with `cache.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| The kv store with an in-process tier | `kv` | yes |  |
| In-process only | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-CACHE-0011` | check | `cache.scope: public` on an action whose `risk` isn't `read`, whose method isn't `GET`, or that declares a `permission` |
| `MAIN-CACHE-0012` | check | An app-scoped `cache.wrap` or `cache.set` holds the result of a `db` read, which is owner or tenant data |
| `MAIN-CACHE-0001` | runtime |  |
| `MAIN-CACHE-0002` | runtime |  |
| `MAIN-CACHE-0003` | runtime |  |
| `MAIN-CACHE-0004` | runtime |  |
| `MAIN-CACHE-0005` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Private by default on credentialed responses | 1 | 0.4 | `http-cache#headers` |
| Declared public caching on read actions | 1 | 0.4 | `http-cache#public-actions` |
| ETags and 304 after the access check | 1 | 0.4 | `http-cache#etag` |
| Wrap with single flight and stale-while-revalidate | 1 | 0.4 | `http-cache#wrap` |
| Fail-static reads | 1 | 0.4 | `http-cache#fail-static` |
| Key and tag invalidation across instances | 1 | 0.4 | `http-cache#invalidation` |
| No personal data in app-scoped entries; erased with the tenant | 1 | 0.4 | `http-cache#privacy` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
cache:
  driver: kv          # uses the kv store, with an in-process tier in front
  timeout: 50ms       # a slower store read is skipped; your function runs instead
```

```ts title="lib/products.ts"
import { cache } from "mainsdk/cache";

declare function loadTopProducts(): Promise<{ id: string }[]>;   // your slow query

export function topProducts() {
  return cache.wrap("top-products", () => loadTopProducts(), { ttl: "5m", swr: "1h", tags: ["products"] });
}

export function productsChanged() {
  return cache.delete({ tag: "products" });   // every instance drops it, in this tenant only
}
```

You should see `loadTopProducts` run once per tenant however many requests arrive, and run again after `productsChanged`.

## Configuration

```yaml title="main.yaml"
cache:
  driver: kv          # kv (default when the app has a kv store) | local
  timeout: 50ms
  max-value: 256KB
  memory: { max-entries: 1000, max-ttl: 30s }
```

| Key | Default | What it does |
| --- | --- | --- |
| `driver` | `kv` with a kv store, else `local` | `kv` keeps entries in the kv store with an in-process tier in front; `local` keeps them in this process only |
| `timeout` | `50ms` | A store call slower than this is skipped, and your function runs instead |
| `max-value` | `256KB` | The largest JSON value stored; a bigger one is not stored (`MAIN-CACHE-0003`) |
| `memory.max-entries` | `1000` | The most entries the in-process tier holds |
| `memory.max-ttl` | `30s` | The longest an entry lives in the in-process tier |

## What you get

| Call | What it does |
| --- | --- |
| `cache.wrap(key, fn, { ttl, swr?, tags?, scope? })` | The stored value while it is fresh; otherwise one call of `fn` per key per instance, whatever the number of waiting callers. Between `ttl` and `ttl + swr` it returns the old value at once and refreshes it once in the background. |
| `cache.get(key, { scope? })` | The fresh value, or `undefined` when there is none or the store did not answer. |
| `cache.set(key, value, { ttl, tags?, scope? })` | Store a JSON value. |
| `cache.delete(key \| { tag }, { scope? })` | Remove one key or every entry with a tag, in the tenant (or `scope: "app"`), on every instance. Resolves with the number removed. |

`scope` is `"tenant"` by default: the stored key is `t:<tenant>:r:<region>:<key>`, from the current context. `scope: "app"` stores `app:<key>` for every tenant and must hold no personal data: a value with an email, phone number, card number or secret in it is not stored (`MAIN-CACHE-0004`), and `mainsdk check` refuses an app-scoped cache around a `db` read (`MAIN-CACHE-0012`).

## Share a read action with a CDN

Shared caching is a declaration on a read action, never a header you write by hand.

```ts title="actions/products.ts"
import { action, s } from "mainsdk";
import { serveRead } from "mainsdk/cache";

declare function loadProducts(): Promise<{ id: string }[]>;   // your catalogue query

export const listProducts = action({
  name: "products.list", intent: "List the public catalogue", risk: "read",
  input: s.object({}),
  http: { method: "GET", path: "/products", cache: { scope: "public", maxAge: "60s", swr: "5m" } },
  run: () => loadProducts(),
});

// in the route handler:  return serveRead(listProducts, request, { log });
```

The response carries `Cache-Control: public, max-age=60, stale-while-revalidate=300` and a weak `ETag`; a request with a matching `If-None-Match` gets `304` after the access check, so a caller without access still gets their 403. If `run` reads the session, an API key or the tenant, the response goes out `private, no-store` and the error `MAIN-CACHE-0002` is logged. A public action with no `permission` is open to everyone. `scope: "private"` with `maxAge` gives `private, max-age=<s>` on any read action.

## What it always does

- Keys carry the tenant and region; no call can read another tenant's entry (ADR-0073).
- A response to a request with a session cookie, an `Authorization` header, an API key or a new cookie goes out `private, no-store`, and a `public`, `s-maxage` or bare `max-age` directive set by a handler is replaced, its `CDN-Cache-Control` removed, and the route logged once (`MAIN-CACHE-0001`). Every adapter applies this after your handler.
- A slow or failed cache store never fails or blocks a request (`MAIN-CACHE-0005`).
- Read actions answer with an `ETag` and a `304` after the access check.
- Deleting a key or tag reaches every instance through [`events.broadcast`](https://mainsdk.dev/docs/interfaces/jobs); if the broadcast fails, in-process entries expire within `cache.memory.max-ttl`.
- Erasing a tenant deletes every entry under its prefix.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-CACHE-0001` | A credentialed response carried a shared-cache directive; it was replaced with `private, no-store` (logged once per route) | Declare shared caching on a read action instead of setting the header |
| `MAIN-CACHE-0002` | A `public` read action read a session, an API key or the tenant; the response went out `private, no-store` | Stop reading the credential in `run`, or make the action `private` |
| `MAIN-CACHE-0003` | A key is longer than 256 characters, or a value is not JSON or is over `max-value` (400); nothing is stored | Use a shorter key or a smaller JSON value |
| `MAIN-CACHE-0004` | An app-scoped value matched a personal-data detector; it was not stored | Use the default tenant scope, or drop the personal data |
| `MAIN-CACHE-0005` | The store was slower than `timeout` or failed; calls are skipped until it answers (logged once per outage) | Restore the store; no restart is needed |
| `MAIN-CACHE-0011` | `mainsdk check`: a `public` cache on an action that is not a `GET` read, or that declares a `permission` | Make it a `GET` read action with no `permission`, or use `scope: "private"` |
| `MAIN-CACHE-0012` | `mainsdk check`: an app-scoped `cache.wrap` or `cache.set` around a `db` read | Use the tenant scope for owner or tenant data |

Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

Change `driver` in `main.yaml`. `kv` shares entries across instances through the kv store, so a delete reaches all of them. `local` keeps entries in each process and needs no store. `cache.wrap`, `cache.get`, `cache.set` and `cache.delete` do not change.

## Testing

Build the app with `createTestApp()` and the `local` driver, and the cache runs in process with no store. `new Cache(opts)` takes a store and a clock for the slow-store and expiry cases. See [Test your app](https://mainsdk.dev/docs/guides/testing).

## Helpers for wiring and tests

| Call | What it does |
| --- | --- |
| `new Cache(opts)` | The server cache of one app instance, for tests and custom wiring; apps use `cache.wrap`. |
| `parseCache(raw, hasKv?)` | Read the `cache` section of `main.yaml` into settings. |
| `cacheIssues(raw)` | List what is wrong in the `cache` section, without throwing. |
| `etagMatches(header, etag)` | True when an `If-None-Match` header matches an ETag. |
| `weakEtag(output)` | A weak ETag for an action's output. |

## Next

- [Key-value store](https://mainsdk.dev/docs/interfaces/kv)
- [Typed API and OpenAPI document](https://mainsdk.dev/docs/interfaces/api)
- [Tenants](https://mainsdk.dev/docs/interfaces/tenant)
- [Cache safely](https://mainsdk.dev/docs/guides/cache-safely)
