Frontend / APIs & data fetching / 01_rest_graphql_trpc_comparison.md

REST vs GraphQL vs tRPC — When Each Wins

Updated 4 interview angles 6 min read source
On this page12
  1. When is REST the right default?
  2. When does GraphQL actually pay for itself?
  3. When is tRPC the right call?
  4. One-line trade-off summary?
  5. What problems does GraphQL cause that REST doesn’t?
  6. What about file uploads, binary, streaming?
  7. What’s a “BFF” and where does it fit?
  8. How do these interact with caching?
  9. Gotchas
  10. Related
  11. Further reading
  12. Interview angle

REST vs GraphQL vs tRPC — When Each Wins

REST is the universal default — HTTP semantics, every cache layer understands it, every client supports it. GraphQL solves over-fetching and under-fetching by letting the client declare its shape — at the cost of operational complexity (N+1 on the server, schema lifecycle, caching nuance). tRPC trades portability for end-to-end TypeScript — the client gets typed RPCs as if they were local functions, but only works in a same-monorepo TS/TS stack. The senior answer is “depends on the team shape and the client mix” — not religion.

When is REST the right default?

  • Multiple unknown clients (mobile, third parties, partners). HTTP semantics + OpenAPI generate clients in every language.
  • Heavy caching at the edge. GET responses are cacheable by every CDN, browser, reverse proxy.
  • Resource-shaped domain (CRUD over users, orders, payments). Maps cleanly to nouns + verbs.
  • Team familiarity. No one needs to learn anything new.

Cost: over-fetching (the endpoint returns more than the screen needs) and N round-trips when the UI needs related entities.

When does GraphQL actually pay for itself?

graphql
# One request, exactly the fields this screen renders.
query OrderScreen($id: ID!) {
  order(id: $id) {
    total
    customer { name }          # would be a second REST call
    lines { sku qty }          # and a third
  }
}
  • One backend, many frontends with different shapes (web + iOS + Android, each needing different fields).
  • Many-to-many entity navigation (think dashboards, social feeds, multi-entity admin views).
  • Public API for unknown consumers who want to query their own shape (GitHub’s GraphQL is the textbook case).
  • Teams that can operate it. GraphQL pushes complexity onto the server: dataloader (N+1), persisted queries (caching), query depth/cost limits (DoS), schema evolution.

It loses when most calls are simple CRUD with no shape variation — you’ve added a query planner for nothing.

When is tRPC the right call?

End-to-end TypeScript monorepo where the same team owns both ends.

  • Zero codegen — types flow from server to client by importing the router type.
  • Refactor the server, the client breaks at compile time (the dream).
  • Excellent DX with TanStack Query integration.
ts
// server/router.ts
export const appRouter = router({
  order: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => db.order.find(input.id)),
})
// A *type*, not a schema.
export type AppRouter = typeof appRouter
ts
// client — no codegen step, no schema file, no build artefact.
import type { AppRouter } from '../server/router'

const trpc = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url })],
})
// fully typed
const order = await trpc.order.query({ id })

Note what crosses the boundary: a TypeScript import type. That is exactly why it is a monorepo tool — the client’s type-checking depends on having the server’s source.

It loses the moment you have a non-TS client (mobile, third party, partner), a separate FE/BE repo, or a public API contract. The “type safety” is type coupling; it’s an asset inside a monorepo and a liability outside.

One-line trade-off summary?

REST GraphQL tRPC
Wire format JSON over HTTP verbs one POST endpoint, JSON JSON over HTTP
Schema OpenAPI (optional) required, typed TS types (no schema artifact)
Over-fetching yes — endpoint shape fixed client picks fields client picks call args (not field shape)
HTTP caching native hard (one POST endpoint) hard (POST)
N+1 risk server controls SQL needs dataloader discipline server controls SQL
Client codegen OpenAPI / hand-rolled graphql-codegen none — import the router type
Cross-language yes yes no (TS only)
Real-time separate (SSE/WS) Subscriptions Subscriptions (over WS)
Discoverability OpenAPI/Swagger introspection / GraphiQL TS autocomplete

What problems does GraphQL cause that REST doesn’t?

  • N+1 on resolvers — one outer list resolver triggers N inner queries unless dataloader batches.
  • Caching — single POST endpoint, body-dependent — CDNs can’t cache by default. Persisted queries + GET fix it but require infrastructure.
  • Query cost / depth limits — an attacker (or a bad query) can request a deeply nested expansion that nukes the server. You have to enforce depth/complexity limits.
  • Schema lifecycle — every additive field affects every client; breaking changes require careful deprecation.
  • Authorization is per-field, not per-endpoint — easy to leak data through a field on a related type.

What about file uploads, binary, streaming?

REST handles them natively (multipart, octet-stream, range requests, ETag). GraphQL needs multipart-spec or just punts to a separate REST endpoint — most teams do keep uploads on REST even in GraphQL apps. tRPC supports them via standard fetch.

What’s a “BFF” and where does it fit?

Backend-For-Frontend — a thin server that aggregates upstream APIs and reshapes them for one client. Often you put GraphQL or a tRPC layer on the BFF rather than expose it to all upstream services. Solves the “many clients” problem without making every service speak GraphQL.

How do these interact with caching?

  • REST — HTTP cache (CDN, browser, reverse proxy), Cache-Control + ETag, plus app cache (TanStack Query / SWR / RTK Query).
  • GraphQL — HTTP caching is awkward (one POST). The cache lives in the client normalized store (Apollo, Relay, urql) keyed by __typename + id. Persisted queries enable HTTP/CDN caching.
  • tRPC — POST by default; HTTP caching same problem. App cache via TanStack Query.

Gotchas

  • “GraphQL is faster than REST” — usually not at the network layer. It’s fewer round trips for graph-shaped reads, but each query can be slower on the server.
  • GraphQL N+1 is the production-killer. Onboarding GraphQL without dataloader (or equivalent) burns the database.
  • tRPC over the public internet — it’s just JSON over HTTP, so you can — but you’ve thrown away the type-safety benefit, since external clients don’t share your TS types.
  • REST “RESTful purity” debates are usually a distraction. Most production REST is “JSON over HTTP with sensible URLs” and that’s fine.
  • GraphQL subscriptions add a separate WebSocket transport and operational surface; for simple real-time, SSE on REST is often enough.

Further reading

Interview angle 4

  • “REST, GraphQL or tRPC?” - REST is the default and the burden of proof is on the alternatives. The real question is who owns both ends, how uniform the clients are, and where the cache lives - not which is technically nicer.
  • “What does GraphQL actually solve, and what does it cost?” - it solves over-fetching and the round trips a mobile client makes to assemble one screen. It costs you N+1 risk on every resolver, HTTP caching that no longer works because everything is one POST, per-field authorization, and a schema with a deprecation lifecycle.
    • Follow-up: “So what has to be in place before you would run it?” - dataloader batching, persisted queries so clients cannot send arbitrary documents, and depth and cost limits. Without those, one client query can take the database down and it will not look like an attack.
  • “When is tRPC the right call?” - inside a TypeScript monorepo where one team owns the client and the server. The end-to-end type safety is genuinely excellent, and it is type coupling - which is a feature inside that boundary and a liability the moment a third party, a mobile app or another language needs the API.
  • “Where do file uploads go?” - REST, even in a GraphQL application. Binary data does not fit the query model cleanly, and the multipart spec around it is more friction than a plain endpoint.