Your API style determines how your team thinks about data, how clients evolve independently of servers, and how much friction exists between backend changes and frontend code.
The Same Resource, Three Ways
A user profile with recent posts:
// REST — two round trips, types maintained manually
const user = await fetch(`/api/users/${id}`).then(r => r.json())
const posts = await fetch(`/api/users/${id}/posts?limit=5`).then(r => r.json())
// GraphQL — one query, client controls the shape, no extra fields
const { data } = await client.query({
query: gql`query { user(id: $id) { name, bio, recentPosts(limit: 5) { title, slug } } }`,
variables: { id }
})
// tRPC — one call, fully typed from server return type, no schema
const { data } = trpc.users.getProfile.useQuery({ id, postLimit: 5 })
// data.user and data.posts are typed — TypeScript fails if server changes
Type Safety: The Spectrum
| REST | GraphQL | tRPC | |
|---|---|---|---|
| Type source | Manual or OpenAPI codegen | Schema + codegen | Server return types, automatically |
| Sync required | After API change | After schema change | None — compiler fails immediately |
| Code generation | Optional | Required for full safety | Never needed |
tRPC's key differentiator: change the server return type, TypeScript fails in the client at compile time. No schema to update, no generator to run.
Over-fetching
REST returns fixed shapes regardless of what the client needs. GraphQL solves this at the query level:
# Profile page needs everything
query ProfilePage($id: ID!) {
user(id: $id) { name, bio, avatarUrl, recentPosts(limit: 5) { title, slug } }
}
# Comment list only needs name + avatar — no other user fields come back
query CommentList($postId: ID!) {
comments(postId: $postId) {
content
author { name, avatarUrl }
}
}
tRPC controls over-fetching through procedure design — you create separate procedures for different use cases. More explicit, but doesn't allow ad-hoc field selection.
Caching
REST maps to HTTP — CDNs, reverse proxies, and browsers cache GET responses automatically via headers.
GraphQL typically uses POST (even for reads), which HTTP caches ignore by default. Apollo Client and urql provide powerful client-side normalized caches, but CDN-level caching requires extra configuration.
tRPC uses React Query's client-side cache. CDN caching requires wrapping queries as GET requests.
// REST — cache built into every HTTP layer
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
// Cloudflare/Fastify caches this without any configuration
// GraphQL — CDN caching needs persisted queries + configuration
// tRPC — React Query cache (client-side, not CDN)
Client Flexibility
| REST | GraphQL | tRPC | |
|---|---|---|---|
| Browser (fetch) | ✅ | ✅ | TS only |
| Mobile (Swift/Kotlin) | ✅ | ✅ | ❌ |
| Third-party integrations | ✅ | ✅ | ❌ |
| curl / Postman | ✅ | ✅ | Possible |
If anyone other than your TypeScript frontend will consume this API, tRPC is not the right primary choice.
Decision Framework
| Situation | Choose |
|---|---|
| Public API with external consumers | REST |
| Mobile + web + third-party clients | REST or GraphQL |
| Multiple frontends, different data shapes | GraphQL |
| CDN caching is critical | REST |
| Real-time subscriptions | GraphQL |
| Full-stack TypeScript, one frontend | tRPC |
| Monorepo, tightest type loop | tRPC |
| Non-TypeScript clients exist | REST or GraphQL |
The practical heuristic: if someone outside your TypeScript frontend will consume the API, tRPC isn't the right primary choice. Multiple clients with different data shapes → GraphQL earns its complexity. One TypeScript frontend where type safety is the priority → tRPC wins on DX.
Hybrid approaches are common: REST for public webhooks and external consumers, tRPC for the internal dashboard, GraphQL for the mobile app — because the right choice depends on the consumer, not just the backend.
Full article at stacknotice.com/blog/graphql-vs-rest-vs-trpc-2026
Top comments (0)