Learning tRPC - tRPC Performance & Optimization
Episode 13 of 19

Learning tRPC - tRPC Performance & Optimization

This episode optimizes a tRPC application: trimming round-trips with request batching, using dehydrate and hydration in SSR so the client does not refetch, as well as server-side optimizations with caching, data loaders, and lazy routers.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

In production, speed is a feature. Episode 13 discusses how to cut tRPC application latency from three sides: reducing round-trips on the client, minimizing refetching with SSR hydration, and optimizing the server side with caching, data loaders, and lazy routers.

You will learn that the best optimization starts with reducing unnecessary work, not merely making a single piece of work faster.

Optimizing Batch Requests

Trimming Round-Trips

Round-trips are the enemy of latency. Every HTTP request has a fixed cost — TCP handshake, TLS, and network latency. httpBatchLink combines concurrent queries into one request:

Automatic batching in tRPC React
const { data: user } = trpc.user.byId.useQuery({ id: 1 });
const { data: posts } = trpc.post.list.useQuery({ authorId: 1 });
const { data: comments } = trpc.comment.list.useQuery({ postId: 2 });

The three queries above are rendered in one component and wrapped in httpBatchLink — tRPC React combines them into a single HTTP round-trip, not three. The more queries per page, the bigger the saving.

Combining Data Manually

Batching works for parallel queries. For data that is always needed together, consider the aggregate procedure from episode 12:

Aggregate procedure
dashboard: t.procedure.query(async ({ ctx }) => {
  const [stats, recentPosts, notifikasi] = await Promise.all([
    ambilStats(),
    ambilRecentPosts(),
    ambilNotifikasi(ctx.userId),
  ]);
  return { stats, recentPosts, notifikasi };
});

Promise.all runs three parallel data accesses within a single procedure — one round-trip to the client, but three database queries running concurrently.

Dehydrate and Hydration in SSR

Why SSR Needs Hydration

In Server-Side Rendering, the page is rendered on the server and sent as HTML. Without dehydration, the browser will refetch all the data the server already rendered — wasteful. tRPC plus React Query solves this with dehydrate:

Dehydrating queries on the server (App Router)
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { createCaller } from "@/server/api/root";
 
export default async function ProfilePage() {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery({
    queryKey: ["user.byId", { id: 1 }],
    queryFn: () => caller.user.byId({ id: 1 }),
  });
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Profile />
    </HydrationBoundary>
  );
}

queryClient.prefetchQuery fills the cache on the server, then dehydrate(queryClient) hands that cache to the client as a prop. The Profile component using useQuery will read the cache directly — without an extra fetch when the browser renders.

Benefits for Latency and SEO

With hydration, the same data is sent once in the HTML; the browser does not need to wait for additional requests to display the first content. This speeds up Time to First Byte and makes content indexable by search engines. Episode 16 will integrate this pattern fully with Next.js.

Server-Side Optimization

Response Caching

For data that rarely changes, cache the procedure result:

Simple caching with a Map
const cache = new Map<string, { data: unknown; expires: number }>();
 
const ambilDenganCache = async (key: string, fetcher: () => Promise<unknown>) => {
  const item = cache.get(key);
  if (item && item.expires > Date.now()) return item.data;
 
  const data = await fetcher();
  cache.set(key, { data, expires: Date.now() + 60_000 });
  return data;
};
 
topProducts: t.procedure.query(() =>
  ambilDenganCache("top-products", () => db.product.findTop(10)),
),

A simple Map cache is enough for a single instance with a 60-second TTL. For distributed deployments, move the cache to Redis — the logic pattern stays the same, only the storage changes.

Data Loaders to Avoid N+1

The N+1 problem occurs when one request triggers many small queries — for example fetching 10 posts and then querying the author 10 times. The solution: batch queries in a single resolver, or use the DataLoader library:

Batching author queries
listWithAuthors: t.procedure.query(async () => {
  const posts = await db.post.findMany();
  const authorIds = [...new Set(posts.map((p) => p.authorId))];
  const authors = await db.user.findMany({ where: { id: { in: authorIds } } });
  const byId = new Map(authors.map((a) => [a.id, a]));
 
  return posts.map((p) => ({ ...p, author: byId.get(p.authorId) }));
});

One query for all posts, one query for all authors, then combined in memory — not 11 queries.

Lazy Routers

Large applications have rarely accessed routers. Split them into separate files and load them on demand. tRPC v11 supports lazy routers that are only loaded when their procedure is called, so server startup does not execute heavy router code:

Lazy router in tRPC v11
const appRouter = t.router({
  user: userRouter,
  analytics: t.router({}, {
    lazy: () => import("./routers/analytics"),
  }),
});

With this pattern, heavy dependencies belonging to analytics are only pulled in when its procedure is actually called — speeding up startup and lowering memory usage.

Tip

Order optimizations by impact: reduce round-trips and refetching first, then caching, then think about micro-details. Data that is no longer refetched does not need caching.

Conclusion

Episode 13 optimizes tRPC at three layers: batching and aggregation trim round-trips on the client, dehydrate-hydration prevents refetching in SSR, and caching, data loaders, and lazy routers lighten the server's load.

Key takeaways:

  • httpBatchLink combines parallel queries into one request.
  • Aggregate procedures with Promise.all trim extra round-trips.
  • dehydrate and HydrationBoundary avoid refetching after SSR.
  • TTL caching stores results of procedures that rarely change.
  • Batching author queries prevents the N+1 problem.
  • Lazy routers load heavy code only when called.

In the next episode, episode 14, we will discuss observability, tracing & monitoring — monitoring tRPC calls with OpenTelemetry, metrics, and logs, tracing requests end-to-end on the client and server, and debugging with devtools and the built-in logger.

Learning tRPC - tRPC Performance & Optimization | Learning tRPC