The Question Behind The Question

"Should we use GraphQL for the new service?" almost never means what it says. It usually means "we have three frontend teams, two mobile clients, an inconsistent API surface, and one tired backend lead — what shape would actually help us?"

REST, GraphQL, and tRPC aren't competing technologies. They're competing answers to a more interesting question: where do you want the contract between your server and your client to live, and who pays the cost of changing it?

Get that right and the rest of the decision is small. Get it wrong and you'll spend a year migrating.

REST: The Boring Default That Still Wins

REST is older than most engineers reading this. It's also still the right answer for most public APIs, most internal microservices, and most "we just need an endpoint" situations.

TypeScript
import express from 'express';
import { z } from 'zod';

const app = express();
app.use(express.json());

const CreateUser = z.object({
  email: z.string().email(),
  name: z.string().min(1),
});

app.post('/v1/users', async (req, res) => {
  const body = CreateUser.parse(req.body);
  const user = await db.user.create({ data: body });
  res.status(201).json(user);
});

Why it keeps winning:

  1. HTTP caching is free. GET /v1/users/42 is cacheable by every CDN, browser, and proxy on the planet without you writing a line.
  2. Tooling is everywhere. OpenAPI, Postman, curl, every observability tool — they all speak HTTP verbs and status codes.
  3. The semantics are visible in the URL. Junior devs and ops engineers can read your access logs and know what happened.

What it costs: clients often over-fetch, related resources require multiple round trips, and your endpoints multiply as your UI gets richer. None of that is fatal — pagination, sparse fieldsets, and include= parameters get you most of the way.

GraphQL: When The Client Drives The Shape

GraphQL is at its best when many different clients (web, iOS, Android, third-party) want different slices of overlapping data, and you're tired of building /v1/dashboard-for-mobile-v3 endpoints. The client asks for exactly the fields it wants; the server resolves them.

TypeScript
import { createYoga, createSchema } from 'graphql-yoga';
import { createServer } from 'node:http';

const yoga = createYoga({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type User { id: ID! email: String! orders: [Order!]! }
      type Order { id: ID! total: Float! }
      type Query { user(id: ID!): User }
    `,
    resolvers: {
      Query: { user: (_, { id }) => db.user.findUnique({ where: { id } }) },
      User: { orders: (u) => db.order.findMany({ where: { userId: u.id } }) },
    },
  }),
});

createServer(yoga).listen(4000);

The Node ecosystem has three serious choices: Apollo Server (the safe, batteries-included default), GraphQL Yoga (lighter, modern, plays nicely with Envelop plugins), and Mercurius for Fastify (very fast, smaller community).

What GraphQL costs you:

  1. Caching is harder. Every query is a POST /graphql with a different body. You either bolt on persisted queries or accept that CDN caching is mostly off the table.
  2. N+1 is the default. You'll add DataLoader on day two and never remove it.
  3. Authorization gets fiddly. Field-level permissions are powerful and tedious. You will write a permission helper, you will misuse it, you will fix it.
  4. Schema discipline matters. Without a schema review process, your GraphQL API turns into a swamp of overlapping types in six months.

GraphQL is brilliant when your bottleneck is "shipping client features fast across many surfaces." It is overkill when one frontend talks to one backend.

tRPC: End-To-End Types Inside One Monorepo

tRPC v11 is the answer to a very specific situation: you have a TypeScript backend and a TypeScript frontend in the same repo, and you want a function call on the client to be type-checked against the function definition on the server, with zero schema generation step.

TypeScript
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  user: t.router({
    byId: t.procedure
      .input(z.object({ id: z.string() }))
      .query(({ input }) => db.user.findUnique({ where: { id: input.id } })),
    create: t.procedure
      .input(z.object({ email: z.string().email() }))
      .mutation(({ input }) => db.user.create({ data: input })),
  }),
});

export type AppRouter = typeof appRouter;
TypeScript
// client/some-component.tsx
import { trpc } from './trpc';
const { data } = trpc.user.byId.useQuery({ id: '42' });
// data is fully typed — change the server, the client breaks at compile time

When tRPC is the right answer:

  1. Same TypeScript repo. Frontend and backend share types via a workspace.
  2. One client, or a few. Web app + maybe an internal admin. Not "we have a public API."
  3. Small team that values shipping over standards. No OpenAPI spec, no schema, no SDKs.

When it's the wrong answer: the moment a third party (mobile, partner, customer) needs to call your API. tRPC is not a public-API technology. There's no schema artifact to publish, and the contract is a TypeScript type — useless to a Java client.

Diagram comparing REST, GraphQL, and tRPC in Node.js across five axes — caching, type safety, tooling, client autonomy, and best fit — with a hybrid setup at the bottom showing REST for the public API, GraphQL for a mobile BFF, and tRPC for an internal admin dashboard.
Three API styles, three different sweet spots — and most mature systems end up using two of them on purpose.

Hybrid Is Underrated

The real production answer in most places I've worked is "two of these, deliberately."

A common pattern: REST as the stable public API for partners and SDKs, GraphQL as a backend-for-frontend layer that aggregates internal services for the mobile app, tRPC for the internal admin tools the company team uses every day. Each surface gets the API style that matches its constraints.

The trick is intent. "We have all three because nobody made a decision" is a problem. "We have REST for partners, GraphQL for the mobile BFF, and tRPC for internal admin" is an architecture.

Type Safety Is Not One Thing

Each option gives you a different flavor of type safety, and conflating them leads to bad arguments:

  1. REST + OpenAPI + generated clients. Static types on both sides via codegen. Works across languages. Your CI has to run codegen. Drift is possible if you skip it.
  2. GraphQL + codegen. Schema is the source of truth. Tools like graphql-codegen produce typed hooks. Cross-language. Strongest "schema is documentation" story.
  3. tRPC. Server types are the contract. No schema, no codegen, no drift — but TypeScript-only, and only inside the monorepo.

If your client isn't TypeScript, tRPC's headline benefit evaporates. If your client is TypeScript and you don't ship public APIs, tRPC eliminates a whole category of glue work.

Caching, Auth, And Rate Limiting Are Where Architecture Lives

Whatever you pick, the boring concerns are the ones that decide if it works at scale.

  1. Caching. REST cooperates with HTTP caches; GraphQL needs persisted queries or APQ; tRPC inherits whatever HTTP cache headers you set on its endpoints.
  2. Auth. Token-based auth (JWT, sessions) is identical across the three. Authorization is the divergence: REST does it per route, GraphQL per field, tRPC per procedure.
  3. Rate limiting. Per-route in REST is trivial. Per-operation in GraphQL needs query cost analysis. tRPC procedures are easy to limit individually.
  4. Observability. OpenTelemetry has good integrations for all three. The trace span names are different — endpoints in REST, operation names in GraphQL, procedure paths in tRPC.

Don't pick the API style and then realize three months later you have to invent your own auth model around it. Sketch the boring things first.

A One-Sentence Mental Model

REST when the world has to call you, GraphQL when many clients want different slices of overlapping data, tRPC when one TypeScript app and one TypeScript server share a repo — and most healthy systems end up running two of the three, on purpose.