Cache Helpers
Cache oRPC procedure output with tag-based revalidation, stale-while-revalidate, storage adapters, and a handler plugin that reflects cache tags in HTTP headers.
Installation
npm install @orpc/experimental-cache@betapnpm add @orpc/experimental-cache@betayarn add @orpc/experimental-cache@betabun add @orpc/experimental-cache@betaBasic Usage
The core concept is the CacheStore interface: fetch resolves the entry under a key, filling it once when there is none even for concurrent callers, and revalidate invalidates entries by tag. You can create your own custom store or use one of the provided adapters. A router shares a single store, provided through the request context under the cache/store key, as defined by the CacheContext interface.
const const store: MemoryCacheStorestore = new new MemoryCacheStore(options?: MemoryCacheStoreOptions): MemoryCacheStoreIn-memory cache store with tag-based invalidation, intended for
development, testing, and single-instance deployments. Expired and
revalidated entries are removed lazily on the next `fetch` of their key,
and concurrent callers of one key are coalesced within the process.MemoryCacheStore()
const const entry: CacheEntryentry = await const store: MemoryCacheStorestore.MemoryCacheStore.fetch(key: unknown, fill: () => Promise<unknown>, options?: CacheFetchOptions): Promise<CacheEntry>Resolves the entry stored under `key`, filling it through `fill` when
there is none. Concurrent callers of one key fill once and share that
entry. A stale entry, past `expiresAt` but within `swr`, is returned as is
while one caller refreshes it in the background. Keys may be any
serializable value; implementations encode them stably, so structurally
equal keys resolve the same entry.fetch('planet:1', async () => ({ id: numberid: 1, name: stringname: 'Earth' }), {
CacheFetchOptions.tags?: readonly string[] | undefinedTags associated with the entry. Revalidating any of them invalidates the entry.tags: ['planets', 'planet:1'],
CacheFetchOptions.ttl?: number | undefinedFresh lifetime in seconds. `undefined` means the entry never expires by time.ttl: 60,
})
await const store: MemoryCacheStorestore.MemoryCacheStore.revalidate({ tags }: CacheRevalidateOptions): Promise<void>Invalidates every entry associated with any of the given tags.revalidate({ CacheRevalidateOptions.tags: readonly [string, ...string[]]The tags to revalidate.tags: ['planets'] }) // the next `fetch` fills again
An entry stays fresh for ttl seconds and is retained for an extra swr window afterward, during which fetch still returns it with a past expiresAt while one caller refreshes it in the background. Revalidating a tag invalidates every entry associated with it, fresh or stale.
Adapters
| Name | Adapter for |
|---|---|
MemoryCacheStore |
In-memory storage |
RedisCacheStore |
Redis |
UpstashCacheStore |
Upstash Redis |
BunRedisCacheStore |
Bun’s Redis |
VercelCacheStore |
Vercel Runtime Cache |
experimental_WorkersCacheStore |
Cloudflare Workers Caching, purge only |
Every duration is in seconds, matching what the underlying caches accept.
Keys may be any serializable value. Strings are used verbatim, while anything else is encoded with encodeCacheKey: serialized first, so complex values like Date, Map, or Set become plain JSON, then canonicalized, so structurally equal keys resolve the same entry regardless of property order. Reuse it when implementing your own store.
Every adapter coalesces concurrent callers of one key. The Redis, Upstash, and Bun stores take a lock in Redis within the same script that reads the entry, so a miss costs one round trip to read and lock and one to store and release, and the lock spans processes; it is released when the fill finishes, or after lockTtl if it never does. They share BaseRedisCacheStore from @orpc/experimental-cache/redis, which holds the scripts and the flow, so a store for another Redis-compatible client only has to run a script. The memory and Vercel stores coalesce within the process through MemoryLock from @orpc/shared, which suits stores of your own too.
import { MemoryCacheStore } from '@orpc/experimental-cache/memory'
const store = new MemoryCacheStore({
/**
* Serializer used to encode non-string keys.
*
* @default RPCJsonSerializer
*/
serializer: undefined,
})import { RedisCacheStore } from '@orpc/experimental-cache/redis'
import { createClient } from 'redis'
const client = createClient({ url: 'redis://localhost:6379' })
// RedisCacheStore lazily connects to Redis when needed.
// You can still call `client.connect()` manually, but it is optional.
await client.connect()
const store = new RedisCacheStore(client, {
/**
* The prefix to use for Redis keys.
*
* @default undefined
*/
prefix: undefined,
/**
* Serializer for cached outputs.
*
* @default RPCSerializer
*/
serializer: undefined,
/**
* How long a lock may be held, in seconds, so a crashed holder frees its waiters.
*
* @default 10
*/
lockTtl: 10,
})import { UpstashCacheStore } from '@orpc/experimental-cache/upstash'
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
// Shares its key and envelope format with RedisCacheStore,
// so both can serve the same database.
const store = new UpstashCacheStore(redis, {
/**
* The prefix to use for Redis keys.
*
* @default undefined
*/
prefix: undefined,
/**
* Serializer for cached outputs.
*
* @default RPCSerializer
*/
serializer: undefined,
/**
* How long a lock may be held, in seconds, so a crashed holder frees its waiters.
*
* @default 10
*/
lockTtl: 10,
})import { BunRedisCacheStore } from '@orpc/bun'
import { redis } from 'bun'
// Shares its key and envelope format with RedisCacheStore,
// so both can serve the same database.
const store = new BunRedisCacheStore(redis, {
/**
* The prefix to use for Redis keys.
*
* @default undefined
*/
prefix: undefined,
/**
* Serializer for cached outputs.
*
* @default RPCSerializer
*/
serializer: undefined,
/**
* How long a lock may be held, in seconds, so a crashed holder frees its waiters.
*
* @default 10
*/
lockTtl: 10,
})import { VercelCacheStore } from '@orpc/experimental-cache/vercel'
import { getCache } from '@vercel/functions'
const store = new VercelCacheStore({
/**
* The Vercel Runtime Cache to use. Outside Vercel,
* it falls back to an in-memory cache.
*
* @default getCache()
*/
cache: getCache(),
/**
* Serializer for cached outputs.
*
* @default RPCSerializer
*/
serializer: undefined,
})import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare'
// Workers Caching caches whole responses in front of the Worker via the
// `cache-control` and `cache-tag` plugin headers; this store only purges
// tags on revalidation. Requires `"cache": { "enabled": true }` in your
// wrangler configuration. Purges are scoped to the calling entrypoint,
// tags match case-insensitively, and purge calls always use the Free
// tier rate limits regardless of your plan.
//
// Purges through `cache` from `cloudflare:workers` by default; pass
// a purger such as `ctx.cache` to use another one.
const store = new WorkersCacheStore()Cache Middleware
The cache helper creates middleware that caches the output of procedures. On a hit it returns the cached output without executing the handler, and on a miss it executes the handler and stores the result. Concurrent misses for one key run the handler once and the rest are served from the entry it stores. The key, tags, ttl, swr, and enabled options accept static values or functions of the middleware options and input.
The key is optional: by default it is derived from the procedure path and input. When provided, it is used as given, so procedures sharing a key also share an entry.
import { cache, CacheContext } from '@orpc/experimental-cache'
import { MemoryCacheStore } from '@orpc/experimental-cache/memory'
const findPlanet = os
.$context<CacheContext>()
.input(z.object({ id: z.number() }))
.use(
cache({
key: (_, input) => `planet:${input.id}`,
tags: (_, input) => ['planets', `planet:${input.id}`],
ttl: 60, // Optional fresh lifetime in seconds, default is no expiry
swr: 300, // Optional stale-while-revalidate window in seconds, default is 0
}),
)
.handler(({ input }) => {
return { id: input.id, name: `Planet ${input.id}` }
})
const result = await call(
findPlanet,
{ id: 1 },
{ context: { 'cache/store': new MemoryCacheStore() } },
)
Stale While Revalidate
When an entry is past ttl but within the swr window, the middleware returns the stale output immediately and re-executes the procedure in the background to refresh the entry. Concurrent stale hits refresh once; the cache never serves anything older than ttl + swr.
On runtimes that stop pending work once the response is sent, such as Cloudflare Workers, provide cache/waitUntil through the context so background refreshes can finish:
export default {
async fetch(request, env, ctx) {
const { response } = await handler.handle(request, {
context: {
'cache/store': store,
'cache/waitUntil': ctx.waitUntil.bind(ctx),
},
})
return response ?? new Response('Not Found', { status: 404 })
},
}
The promise it receives rejects when a refresh fails, so cache/waitUntil is also where those failures are handled. Without it they surface as unhandled rejections, so on other runtimes provide one that reports them, for example promise => promise.catch(console.error).
Revalidate Middleware
The revalidate helper creates middleware that revalidates tags after the procedure succeeds, typically on mutations. The required tags option accepts a non-empty list of tags or a function of the middleware options and input. If the procedure throws, or tags resolves to null or undefined, the revalidation is skipped.
import { revalidate } from '@orpc/experimental-cache'
const updatePlanet = os
.$context<CacheContext>()
.input(z.object({ id: z.number(), name: z.string() }))
.use(
revalidate({ tags: (_, input) => ['planets', `planet:${input.id}`] }),
)
.handler(({ input }) => {
return input
})
Handler Plugin
The CacheHandlerPlugin reflects the cache activity of Cache Middleware and Revalidate Middleware into response headers. It does nothing by default; only the headers you list are set:
orpc-cache-tagcarries the tags the response depends on.orpc-cache-tag-invalidationcarries the tags revalidated by the request, useful for invalidating tagged data in client caches.cache-controlandcache-tagare the standard HTTP counterparts for response caches in front, such as CDNs or Cloudflare Workers Caching.
The plugin sets these over anything already on the response. To override them, set your own afterwards with ResponseHeadersPlugin.
Tags are joined with commas. Only %, ,, uppercase letters, and characters that cannot appear in a header value are percent-encoded, so typical tags stay readable. Uppercase letters are encoded because caches like Cloudflare Workers Caching match tags case-insensitively; the encoded form stays unambiguous under case folding. Use decodeCacheTagHeader from @orpc/shared to parse a header back into tags.
import { CacheHandlerPlugin } from '@orpc/experimental-cache'
const handler = new RPCHandler(router, {
plugins: [
new CacheHandlerPlugin({
headers: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'],
}),
],
})