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

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): 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 `get` of their key.
@see{@link https://orpc.dev/docs/helpers/cache#adapters Cache Helpers - Adapters}
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[] | undefined
Tags associated with the entry. Revalidating any of them invalidates the entry.
@default[]
tags
: ['planets', 'planet:1'],
CacheSetOptions.ttl?: number | undefined
Fresh lifetime in milliseconds. `undefined` means the entry never expires by time.
@defaultundefined
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-tag carries the tags the response depends on.
  • orpc-cache-tag-invalidation carries the tags revalidated by the request, useful 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. 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],
    }),
  ],
})

Last updated on August 28, 2026

Was this page helpful?