Skip to content
tRPC

Partners

Response Caching

On this page

Since all tRPC queries are normal HTTP GET requests, you can use standard HTTP cache headers to cache responses. This can make responses snappy, give your database a rest, and help scale your API.

Using responseMeta to cache responses

Most tRPC adapters support a responseMeta callback that lets you set HTTP headers (including cache headers) based on the procedures being called.

This works with any hosting provider that supports standard HTTP cache headers (e.g. Vercel, Cloudflare, AWS CloudFront).

server.ts
import { initTRPC } from '@trpc/server';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';

export const createContext = async (opts: CreateHTTPContextOptions) => {
  return {
    req: opts.req,
    res: opts.res,
  };
};

type Context = Awaited<ReturnType<typeof createContext>>;

export const t = initTRPC.context<Context>().create();

const waitFor = async (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));

export const appRouter = t.router({
  public: t.router({
    slowQueryCached: t.procedure.query(async (opts) => {
      await waitFor(5000); // wait for 5s

      return {
        lastUpdated: new Date().toJSON(),
      };
    }),
  }),
});

// Exporting `type AppRouter` only exposes types that can be used for inference
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export
export type AppRouter = typeof appRouter;

// export API handler
const server = createHTTPServer({
  router: appRouter,
  createContext,
  responseMeta(opts) {
    const { info, errors, type, eagerGeneration } = opts;
    // assuming all your public routes are in the `public` router
    const allPublic = info?.calls.every((call) =>
      call.path.startsWith('public.'),
    );
    // checking that no procedures errored
    // (when streaming, headers are sent before any results are known)
    const allOk = !eagerGeneration && errors.length === 0;
    // checking we're doing a query request
    const isQuery = type === 'query';

    if (allPublic && allOk && isQuery) {
      // cache request for 1 day + revalidate once every second
      const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
      return {
        headers: new Headers([
          [
            'cache-control',
            `s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
          ],
        ]),
      };
    }
    return {};
  },
});

server.listen(3000);

responseMeta runs once per HTTP response, so with batching it decides the headers for every procedure in the batch: only cache if all of them are public. It also gets the request's ctx, e.g. to skip caching for signed-in users. For streamed responses, like those of httpBatchStreamLink, the headers are sent before any procedure has finished, so responseMeta runs with eagerGeneration: true and an empty errors array.

Next.js App Router

With the App Router, tRPC runs in a route handler with the fetch adapter. Next.js sends the route handler's Response as it is, so set Cache-Control in the responseMeta of fetchRequestHandler. Route segment config like export const dynamic = 'force-dynamic' or export const revalidate = 60 doesn't change the header.

app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from './trpc/init';
import { appRouter } from './trpc/routers/_app';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createTRPCContext({ headers: req.headers }),
    responseMeta(opts) {
      const { info, errors, type, eagerGeneration } = opts;
      // assuming all your public routes are in the `public` router
      const allPublic = info?.calls.every((call) =>
        call.path.startsWith('public.'),
      );
      const allOk = !eagerGeneration && errors.length === 0;
      const isQuery = type === 'query';

      if (allPublic && allOk && isQuery) {
        return {
          headers: new Headers([
            ['cache-control', 's-maxage=60, stale-while-revalidate=86400'],
          ]),
        };
      }
      return {};
    },
  });

export { handler as GET, handler as POST };

Batching

responseMeta runs once per batch, not once per procedure. With httpBatchLink, a public query made at the same time as a private one shares its request, so the response isn't cached. And a CDN caches each batch by its URL, which holds every procedure and input in the batch, so the same public query rarely hits the cache.

Send cacheable public queries through httpLink with a splitLink, so each one is a GET request of its own, and keep batching the rest. getUrl() is the one from the App Router setup:

trpc/client.tsx
import {
  createTRPCClient,
  httpBatchLink,
  httpLink,
  splitLink,
} from '@trpc/client';
import type { AppRouter } from './routers/_app';

const trpcClient = createTRPCClient<AppRouter>({
  links: [
    splitLink({
      condition: (op) => op.type === 'query' && op.path.startsWith('public.'),
      true: httpLink({ url: getUrl() }),
      false: httpBatchLink({ url: getUrl() }),
    }),
  ],
});