REST vs GraphQL vs tRPC: The Actual Tradeoffs
Architecture Patterns — Part 8 of 30
The Meeting That Wasted Two Weeks
I once watched a team spend two weeks debating API architecture for a new internal dashboard. Slack threads. Architecture review docs. A 90-minute Zoom call with four engineers drawing boxes on a shared whiteboard.
The project? A simple tool for ops staff to view and update customer subscription status. Maybe 15 endpoints. One client: a React app. TypeScript on both ends. Two backend engineers who'd been writing TypeScript for years.
They picked GraphQL.
Six months later, one of those engineers called me. "We've got an N+1 problem that took us three weeks to track down. We added Apollo Client, Apollo Server, graphql-codegen, DataLoader. We're maintaining a schema that mirrors our database. And our frontend team just... queries the exact same shape every time anyway."
I wasn't surprised. I've seen this pattern play out dozens of times: teams pick an API layer based on prestige, familiarity with blog posts, or cargo-culting what big companies do—rather than matching the tool to the actual constraints of the project.
This is Part 8 of the Architecture Patterns series. We're not going to relitigate the endless REST vs GraphQL Twitter wars. We're going to build you a decision framework you can use the next time someone opens a discussion thread called "API Architecture Question."
What You're Actually Choosing Between
Before we get into tradeoffs, let's be precise about what each paradigm actually is.
REST (Representational State Transfer) organizes APIs around resources and HTTP semantics. Each noun in your domain gets a URL. Verbs are HTTP methods. State lives in the resource representations you exchange.
GET /users/42
POST /users
PUT /users/42
DELETE /users/42
GET /users/42/posts
GraphQL replaces the multi-endpoint model with a single endpoint and a query language. Clients describe the shape of the data they want; the server resolves it.
query GetUserWithPosts {
user(id: "42") {
name
email
posts(last: 5) {
title
publishedAt
}
}
}
tRPC takes a fundamentally different approach: it's not a wire protocol at all. It's a TypeScript-native RPC layer that shares types directly between your server and client. No schema language, no codegen step, no separate spec file. You write a backend function; your frontend calls it with full type safety.
// server.ts
const appRouter = router({
user: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
}),
});
// client.ts — TypeScript knows the return type automatically
const user = await trpc.user.byId.query({ id: '42' });
These are not competing implementations of the same thing. They represent different positions on a set of tradeoffs.
The Four Axes That Actually Matter
1. Who consumes the API?
This is the single most important question, and most architecture debates skip it entirely.
If your API is public—consumed by third-party developers, mobile apps from external teams, or partner integrations—REST is almost always the right answer. It's language-agnostic, HTTP-native, and every HTTP client on earth can talk to it. Developers in Ruby, Go, Python, and Swift don't need to install a GraphQL client or understand TypeScript.
In February 2025, Directus summarized it bluntly: "Public API? REST is your friend."
tRPC is explicitly off the table for public APIs. It requires both sides to be TypeScript. This is a feature, not a bug—but it defines tRPC's universe clearly.
GraphQL occupies interesting middle ground here. If your "public" API has complex, nested data needs and you're willing to invest in schema documentation and introspection tooling (Apollo Studio, etc.), GraphQL can be a strong choice. GitHub's public GraphQL API is the canonical example.
If your API is internal—consumed exclusively by your own frontend teams—the calculus shifts dramatically. Type safety and developer experience become first-class priorities, and the language constraint becomes irrelevant if you're already on TypeScript.
2. How dynamic are your data requirements?
This is GraphQL's real superpower—and it's often misunderstood.
The promise of GraphQL isn't "no over-fetching." The promise is client-driven data fetching: the client can ask for different shapes without a backend deploy. This matters enormously in specific contexts:
- Mobile apps where iOS and Android need different field subsets
- Multi-tenant SaaS where different customers have different data models
- Dashboard products where users configure what data they want to see
- Any situation where a single "product" has multiple consuming surfaces
When Airbnb moved parts of their frontend to GraphQL, they cited 10x greater agility at scale—specifically because frontend teams could iterate on data requirements without waiting on backend changes.
But here's the thing: if your frontend always queries the same shape, that benefit evaporates. The team I described at the start had a single React dashboard that always queried exactly the same subscription fields. GraphQL's flexibility bought them nothing. It cost them an N+1 incident and three weeks of DataLoader wrangling.
3. How mature is your type discipline?
REST gives you no type contracts by default. You can add OpenAPI/Swagger on top, but it's extra ceremony—you write the spec, then write the code, and pray they stay in sync.
GraphQL gives you a schema, which is a genuine type contract for your API surface. Tools like graphql-codegen can generate TypeScript types from it. But there's a gap: your schema type and your database type and your resolver implementation can diverge. You own the synchronization.
tRPC eliminates the gap entirely. Your server function IS the type contract. When you change a return type, TypeScript immediately surfaces every client call that breaks. Companies like Cal.com and Documenso migrated production systems to tRPC reporting 60–80% reduction in API-related bugs and 40% faster feature development—largely because refactoring with confidence became possible.
tRPC v11, officially released in March 2025, doubled down on this. It added React Server Component support with prefetch helpers, streaming responses via httpBatchStreamLink, Server-Sent Events for subscriptions, and full TanStack Query v5 compatibility. The library crossed 700k weekly npm downloads. The "TypeScript toy" characterization is outdated.
4. What's the real cost of your data fetching patterns?
GraphQL's Achilles heel in production is the N+1 problem, and it's more dangerous than most introductory content admits.
Here's what happens at scale: you have a query that returns 100 users. Each user has posts. A naive resolver makes one query for users, then one query per user for their posts—101 queries total. Double the users, double the queries.
// This looks innocent. It's a performance trap.
const resolvers = {
User: {
posts: async (parent) => {
// Called N times for N users
return db.posts.findMany({ where: { userId: parent.id } });
}
}
};
The fix—DataLoader batching—works, but it's not automatic. You have to know to implement it, implement it correctly, and create new DataLoader instances per request to avoid cross-request cache poisoning. This is a whole category of operational expertise that REST doesn't require.
REST, meanwhile, lets your backend engineer hand-craft exactly the SQL join they want for each endpoint. Predictable. Debuggable. Cacheable at the HTTP layer with standard CDN infrastructure.
tRPC inherits whatever data fetching pattern you write on the server. You're still writing server functions—you can do DataLoader, ORMs, raw SQL, whatever. The RPC layer is transparent.
Performance: What the Benchmarks Are Actually Telling You
Performance benchmarks for these paradigms circulate constantly, and they're almost all misleading in isolation.
For simple queries, REST averages around 922ms vs GraphQL's 1864ms in benchmark conditions—a 2x gap. But this gap narrows dramatically when GraphQL is doing what it's designed for: fetching multiple related resources in one round trip that REST would handle across three or four requests.
The more useful frame: where does your bottleneck actually live?
For most web applications, the bottleneck is database queries, not serialization or HTTP overhead. A well-written GraphQL resolver with proper DataLoader batching will outperform a series of poorly-written REST endpoints every time. A single clean REST endpoint with a good SQL join will outperform a naive GraphQL resolver with N+1 issues every time.
Choose based on where you'll have the most control, not which paradigm "is faster" in a benchmark.
The Federation Exception
There's one scenario where GraphQL wins decisively and neither REST nor tRPC competes: enterprise-scale API federation.
When an engineering organization has dozens of teams, each owning subgraphs, and they need a unified API layer that aggregates across microservices without forcing the frontend to know which team owns which data—GraphQL Federation (Apollo, Mesh, WunderGraph) is the right architecture.
As WunderGraph's six-year retrospective concluded after building GraphQL tooling since 2019: "Nobody adopts GraphQL because they want to use GraphQL. Enterprises adopt GraphQL because they want the benefits of Federation."
If you're not operating at that scale, this consideration is irrelevant to your decision. But it's the reason GraphQL exists in major tech companies' stacks—and it's distinct from the "GraphQL is better than REST for CRUD" argument you'll see in introductory blog posts.
The Decision Framework
Stop asking "which is better?" Start asking these questions:
1. Is this a public or partner API?
YES → REST. Period.
2. Are both client and server TypeScript?
NO → REST or GraphQL (tRPC is unavailable)
YES → Continue
3. Do multiple surfaces need different data shapes from the same endpoint?
YES → GraphQL
NO → Continue
4. Are you operating microservices across multiple teams?
YES → GraphQL Federation
NO → Continue
5. Is this internal tooling with TypeScript on both ends?
YES → tRPC is almost certainly your best choice
6. Default → REST (simplest operational model)
You can also mix them. REST for your public API. tRPC for your internal dashboard. GraphQL for the mobile app that serves iOS, Android, and web with different data needs. These aren't mutually exclusive.
A 15-Minute tRPC Setup
For the TypeScript-first internal tool case, here's the minimum viable tRPC setup with Next.js App Router (tRPC v11):
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// src/server/routers/subscriptions.ts
export const subscriptionRouter = router({
list: publicProcedure.query(async () => {
return db.subscriptions.findMany({ where: { active: true } });
}),
update: publicProcedure
.input(z.object({
id: z.string(),
status: z.enum(['active', 'paused', 'cancelled'])
}))
.mutation(async ({ input }) => {
return db.subscriptions.update({
where: { id: input.id },
data: { status: input.status }
});
}),
});
// src/app/subscriptions/page.tsx — type-safe, no codegen
import { trpc } from '@/utils/trpc';
export default function SubscriptionsPage() {
const { data } = trpc.subscriptions.list.useQuery();
const update = trpc.subscriptions.update.useMutation();
// ...
}
No schema file. No codegen step. Change the status enum on the server and TypeScript immediately flags the client call. The ops team from the story at the top? They should have started here.
Checklist: Before Your Next API Architecture Decision
- Define your consumers first. External/public → REST. TypeScript-only internal → tRPC. Dynamic multi-surface → GraphQL.
- Map your data shapes. Do different clients need genuinely different subsets of data from the same entity? That's a GraphQL signal.
- Assess your team's GraphQL readiness. N+1 issues, DataLoader patterns, schema governance—are you equipped to manage this operationally?
- Check for federation need. Are you aggregating APIs across multiple autonomous teams? Federation matters at that scale.
- Reject prestige-driven decisions. "Airbnb uses GraphQL" is not a reason. Match the tool to the constraint, not to the resume.
- Validate the TypeScript commitment. tRPC's entire value prop evaporates without full TypeScript on both ends.
- Consider mixing paradigms. One project can legitimately use all three for different surfaces.
- Plan for evolution. Starting REST and adding a GraphQL layer or tRPC adapter later is a valid path. Don't over-engineer Day 1.
Ask The Guild
Community prompt: What's the most expensive API architecture mistake your team has made—and what forced you to recognize it? Did you over-engineer with GraphQL when REST would have been sufficient? Regret REST limitations when you needed flexible querying? Find tRPC's TypeScript constraint a dealbreaker in a polyglot codebase? Share the war story and the lesson in the comments.
Tom Hundley is a software architect with 25 years of experience helping builders make better systems decisions. He writes the Architecture Patterns series for the AI Coding Guild.