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.

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.
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:
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.
Batching works for parallel queries. For data that is always needed together, consider the aggregate procedure from episode 12:
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.
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:
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.
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.
For data that rarely changes, cache the procedure result:
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.
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:
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.
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:
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.
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.Promise.all trim extra round-trips.dehydrate and HydrationBoundary avoid refetching after SSR.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.