Skip to content
You are reading the v2 docs, currently in beta.V1 docs
oRPC
Esc
navigateopen⌘Jpreview
On this page

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@beta
pnpm add @orpc/experimental-cache@beta
yarn add @orpc/experimental-cache@beta
bun add @orpc/experimental-cache@beta

Basic Usage

Everything builds on the CacheStore interface: fetch returns the entry under a key and fills it when there is none, and revalidate invalidates entries by tag. A router shares one store, passed through the request context as cache/store, as the CacheContext interface describes. Use one of these adapters or write your own:

const const store: MemoryCacheStorestore = new new MemoryCacheStore(options?: MemoryCacheStoreOptions): MemoryCacheStore
In-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.
@see{@link https://orpc.dev/docs/helpers/cache#adapters Cache Helpers - Adapters}
MemoryCacheStore
()
const const entry: CacheEntryentry = await const store: MemoryCacheStorestore.BaseKeyValueCacheStore.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[] | undefined
Tags associated with the entry. Revalidating any of them invalidates the entry.
@default[]
tags
: ['planets', 'planet:1'],
CacheFetchOptions.ttl?: number | undefined
Fresh lifetime in seconds. `undefined` means the entry never expires by time.
@defaultundefined
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 is fresh for ttl seconds and kept for a further swr window, during which fetch still returns it with a past expiresAt while one caller refreshes it in the background. Revalidating a tag invalidates every entry carrying it, fresh or stale. Durations are in seconds throughout.

Cache Middleware

The cache helper creates middleware that caches the output of procedures. A hit returns the cached output without running the handler; a miss runs the handler once, even for concurrent callers, and stores the result. The key, tags, ttl, swr, and enabled options accept static values or functions of the middleware options and input.

key defaults to the procedure path and input. When provided, it is used as given, so procedures sharing a key 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

Past ttl but within swr, the middleware returns the stale output at once and re-runs the procedure in the background to refresh the entry. Concurrent stale hits refresh once, and nothing older than ttl + swr is ever served.

On runtimes that stop pending work once the response is sent, such as Cloudflare Workers, pass cache/waitUntil through the context so 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 pass 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 or a function of the middleware options and input. When the procedure throws, or tags resolves to null or undefined, nothing is revalidated. When the store fails to revalidate, the request fails even though the mutation already ran, so the failure is visible; retrying such a request repeats the mutation.

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 activity of Cache Middleware and Revalidate Middleware into response headers. Only the headers you list are set:

  • orpc-cache-tag carries the tags the response depends on.
  • orpc-cache-tag-invalidation carries the tags the request revalidated, for invalidating tagged data in client caches.
  • cache-control and cache-tag are 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, and the encoded form stays unambiguous under case folding. decodeCacheTagHeader from @orpc/shared parses 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'],
    }),
  ],
})

Adapters

Memory

Stores entries in the process. Suited to development, testing, and single-instance deployments.

import { MemoryCacheStore } from '@orpc/experimental-cache/memory'

const store = new MemoryCacheStore({
  /**
   * Serializer used to encode non-string keys.
   *
   * @default RPCJsonSerializer
   */
  serializer: undefined,
})

Redis

Stores entries as Redis hashes and drives every operation through Lua scripts, so a hit costs one round trip and a miss two. The lock taken on a miss is released when the fill finishes, or after lockTtl if it never does. The client is connected lazily when needed.

import { RedisCacheStore } from '@orpc/experimental-cache/redis'
import { createClient } from 'redis'

const client = createClient({ url: 'redis://localhost:6379' })

const store = new RedisCacheStore(client, {
  /**
   * The prefix to use for Redis keys.
   *
   * @default undefined
   */
  prefix: undefined,

  /**
   * Serializer for keys and cached outputs.
   *
   * @default RPCJsonSerializer
   */
  serializer: undefined,

  /**
   * How long a lock may be held, in seconds, so a crashed holder frees its waiters.
   *
   * @default 10
   */
  lockTtl: 10,
})

The Redis, Upstash, and Bun stores share BaseRedisCacheStore from @orpc/experimental-cache/base-redis, which holds the scripts and the flow. A store for another Redis-compatible client only has to run a script.

Upstash

The Redis adapter for Upstash’s REST client. It shares the key and entry format with RedisCacheStore, so both can serve the same database.

import { UpstashCacheStore } from '@orpc/experimental-cache/upstash'
import { Redis } from '@upstash/redis'

const redis = Redis.fromEnv()

const store = new UpstashCacheStore(redis, {
  /**
   * The prefix to use for Redis keys.
   *
   * @default undefined
   */
  prefix: undefined,

  /**
   * Serializer for keys and cached outputs.
   *
   * @default RPCJsonSerializer
   */
  serializer: undefined,

  /**
   * How long a lock may be held, in seconds, so a crashed holder frees its waiters.
   *
   * @default 10
   */
  lockTtl: 10,
})

Bun

The Redis adapter for Bun’s built-in Redis client, from @orpc/bun. It shares the key and entry format with RedisCacheStore, so both can serve the same database.

import { BunRedisCacheStore } from '@orpc/bun'
import { redis } from 'bun'

const store = new BunRedisCacheStore(redis, {
  /**
   * The prefix to use for Redis keys.
   *
   * @default undefined
   */
  prefix: undefined,

  /**
   * Serializer for keys and cached outputs.
   *
   * @default RPCJsonSerializer
   */
  serializer: undefined,

  /**
   * How long a lock may be held, in seconds, so a crashed holder frees its waiters.
   *
   * @default 10
   */
  lockTtl: 10,
})

Vercel

Stores entries in the Vercel Runtime Cache, expiring tags natively through expireTag. Outside Vercel, the default getCache() falls back to an in-memory cache.

import { VercelCacheStore } from '@orpc/experimental-cache/vercel'
import { getCache } from '@vercel/functions'

const store = new VercelCacheStore({
  /**
   * The Vercel Runtime Cache to use.
   *
   * @default getCache()
   */
  cache: getCache(),

  /**
   * Serializer for keys and cached outputs.
   *
   * @default RPCJsonSerializer
   */
  serializer: undefined,
})

Cloudflare Workers Caching

A purge-only store from @orpc/cloudflare. Workers Caching caches whole responses in front of the Worker through the cache-control and cache-tag headers of the Handler Plugin, so every fetch runs the procedure and stores nothing, and revalidate purges the tags from the front cache.

It requires "cache": { "enabled": true } in your wrangler configuration. Purges are scoped to the calling entrypoint, tags match case-insensitively, and purge calls use the Free tier rate limits regardless of your plan.

import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare'

const store = new WorkersCacheStore({
  /**
   * The Workers Caching purge surface, such as `ctx.cache`.
   *
   * @default cache from `cloudflare:workers`
   */
  cache: undefined,
})

Last updated on September 9, 2026

Was this page helpful?