in a midsize SaaS product we hit a scaling wall when dozens of React components started querying overlapping slices of a user record. each component called a separate REST endpoint, resulting in a burst of network round‑trips and duplicated payloads. the latency spike was noticeable on slow 3G connections and the server logs showed a 30 % increase in request handling time.
The bottleneck
the core issue was over‑fetching: the REST contracts were designed around whole‑resource responses (GET /users/:id) because that felt natural during early prototyping. as the UI grew, developers began to compose new views by stitching together these large blobs, even when only a single field (e.g., user.settings.theme) was needed. the browser kept opening new TCP connections, the CDN cache missed because each request had a unique query string, and the API server struggled with throttling.
Switching to tRPC
tRPC lets you expose type‑safe procedures directly from your Next.js server bundle, eliminating the need for a separate HTTP layer for most internal data. a single router can serve granular queries while the client can compose them in one network round‑trip using useQuery. here's a minimal example:
// src/server/routers/user.ts
import { z } from 'zod';
import { createRouter } from '@/server/trpc';
export const userRouter = createRouter()
.query('settings', {
input: z.object({ userId: z.string() }),
resolve: async ({ input }) => {
const user = await prisma.user.findUnique({
where: { id: input.userId },
select: { settings: true }
});
return user?.settings ?? null;
}
})
.query('profile', {
input: z.object({ userId: z.string() }),
resolve: async ({ input }) => {
return prisma.user.findUnique({
where: { id: input.userId },
select: { name: true, avatarUrl: true }
});
}
});
// src/components/UserHeader.tsx
import { trpc } from '@/utils/trpc';
export function UserHeader({ userId }: { userId: string }) {
const { data: profile } = trpc.user.profile.useQuery({ userId });
const { data: settings } = trpc.user.settings.useQuery({ userId });
if (!profile) return null;
return (
<header style={{ background: settings?.theme?.background }}>
<img src={profile.avatarUrl} alt="avatar" />
<h1>{profile.name}</h1>
</header>
);
}
Trade‑offs to watch
- bundle size – exposing procedures from the same bundle means the server code ships to the edge; you must tree‑shake unused routers and keep the bundle under the Vercel edge‑function limit (~50 KB gzipped).
-
caching – REST benefits from HTTP caching semantics out of the box. with tRPC you need to implement manual cache control (e.g.,
Cache-Controlheaders on the response or client‑side SWR). otherwise you lose CDN edge‑caching for high‑traffic queries. -
observability – moving from discrete endpoints to a procedural surface can obscure request‑level metrics. instrument each resolver with tracing IDs and log the
inputshape to retain visibility. -
versioning – REST encourages versioned URLs (
/v1/users). tRPC relies on TypeScript contracts; breaking changes require a coordinated client update or a runtime guard that returns a deprecation warning.
When it makes sense
if your UI is component‑driven and the data surface is highly granular, tRPC reduces round‑trip overhead and keeps type safety across the stack. if you need broad public APIs, CDN‑friendly caching, or language‑agnostic consumption, a well‑designed REST layer still has merit.
what patterns have you used to balance fine‑grained fetching with cacheability in your Next.js apps?
Top comments (0)