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, which defines a standard way to store, look up, and invalidate cached output by tags. 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 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 `get` of their key.MemoryCacheStore()
await const store: MemoryCacheStorestore.MemoryCacheStore.set(key: unknown, output: unknown, options?: CacheSetOptions): Promise<void>Stores `output` under `key`, replacing any previous entry.set('planet:1', { id: numberid: 1, name: stringname: 'Earth' }, {
CacheSetOptions.tags?: readonly string[] | undefinedTags associated with the entry. Revalidating any of them invalidates the entry.tags: ['planets', 'planet:1'],
CacheSetOptions.ttl?: number | undefinedFresh lifetime in milliseconds. `undefined` means the entry never expires by time.ttl: 60_000,
})
const const entry: CacheEntry | undefinedentry = await const store: MemoryCacheStorestore.MemoryCacheStore.get(key: unknown): Promise<CacheEntry | undefined>Resolves the entry stored under `key`, or `undefined` on miss/evicted/revalidated.
Stale entries (past `expiresAt` but within the stale-while-revalidate window) are returned.
Keys may be any serializable value; implementations encode them stably,
so structurally equal keys resolve the same entry.get('planet:1')
await const store: MemoryCacheStorestore.MemoryCacheStore.revalidateTag(tag: string | readonly string[]): Promise<void>Invalidates every entry associated with one or many tags.revalidateTag('planets') // now `get` misses
An entry stays fresh for ttl milliseconds and is retained for an extra swr window afterward, during which get still returns it with a past expiresAt so callers can serve it stale while refreshing. Revalidating a tag invalidates every entry associated with it, fresh or stale.
Adapters
| Name | Adapter for |
|---|---|
MemoryCacheStore |
In-memory storage |
RedisCacheStore |
Redis |
VercelCacheStore |
Vercel Runtime Cache |
experimental_KVCacheStore |
Cloudflare Workers KV |
experimental_WorkersCacheStore |
Cloudflare Workers Caching, purge only |
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.
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({
/**
* The Redis client to store entries in. Connected lazily when needed.
*/
redis: client,
/**
* The prefix to use for Redis keys.
*
* @default undefined
*/
prefix: undefined,
/**
* Serializer for cached outputs. Outputs containing Blob or File
* values are ignored and never stored.
*
* @default RPCSerializer
*/
serializer: undefined,
})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. Outputs containing Blob or File
* values are ignored and never stored.
*
* @default RPCSerializer
*/
serializer: undefined,
})import { experimental_KVCacheStore as KVCacheStore } from '@orpc/cloudflare'
export default {
async fetch(request, env) {
// KV is eventually consistent: writes and revalidations may take
// 60 seconds or more to be visible in other locations.
const store = new KVCacheStore({
/**
* The KV namespace to store entries in.
*/
kv: env.CACHE_KV,
/**
* The prefix to use for KV keys.
*
* @default undefined
*/
prefix: undefined,
/**
* Serializer for cached outputs. Outputs containing Blob or File
* values are ignored and never stored.
*
* @default RPCSerializer
*/
serializer: undefined,
})
},
}import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare'
export default {
async fetch(request, env, ctx) {
// 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.
const store = new WorkersCacheStore({ cache: ctx.cache })
},
}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. 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, strings are used verbatim, while any other serializable value is combined with the procedure path and encoded into a key.
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_000, // Optional fresh lifetime, default is no expiry
swr: 300_000, // Optional stale-while-revalidate window, default is 0
}),
)
.handler(({ input }) => {
return { id: input.id, name: `Planet ${input.id}` }
})
const result = await call(
findPlanet,
{ id: 1 },
{ context: { cache: 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 may each trigger a refresh; 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 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,
waitUntil: ctx.waitUntil.bind(ctx),
},
})
return response ?? new Response('Not Found', { status: 404 })
},
}
Revalidate Middleware
The revalidate helper creates middleware that revalidates tags after the procedure succeeds, typically on mutations. It accepts one tag, a non-empty list of tags, or a function of the middleware options and input. If the procedure throws, 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((_, 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. They are only set on GET and HEAD responses and never override existing headers.
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 to parse a header back into tags.
import { CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER, CacheHandlerPlugin } from '@orpc/experimental-cache'
const handler = new RPCHandler(router, {
plugins: [
new CacheHandlerPlugin({
headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER],
}),
],
})