Picking an API pattern in 2025 means choosing between four mature options that each solve a different problem. REST is still the default for around 67% of public APIs because tooling is everywhere and HTTP caching is free. GraphQL has taken over mobile (about 42% adoption) by cutting over-fetching and letting clients ask for exactly what they need. gRPC is what high-throughput microservices reach for when REST starts costing you 10x on latency. tRPC is the TypeScript answer for full-stack monorepos that want end-to-end type safety without code generation.
Netflix uses all four. REST for public APIs, GraphQL for mobile, gRPC for the 8000-plus internal services, tRPC for admin tools. That is the honest answer to "which one": probably more than one, depending on the audience. This piece walks through how each pattern behaves under real load, where it breaks down, and the migration paths I see teams actually take. For the developer-experience side of any of these, see my guide on APIs developers love using.
How each one actually works
REST (Representational State Transfer)
Resource-oriented architecture using HTTP methods:
// REST API Design
// Resources: /users, /orders, /products
// GET /users/:id - Retrieve user
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt
});
});
// GET /users/:id/orders - Retrieve user's orders
app.get('/users/:id/orders', async (req, res) => {
const orders = await db.orders.findByUserId(req.params.id);
res.json({
orders: orders.map(o => ({
id: o.id,
total: o.total,
status: o.status,
createdAt: o.createdAt
}))
});
});
// POST /orders - Create order
app.post('/orders', async (req, res) => {
const order = await db.orders.create({
userId: req.body.userId,
items: req.body.items,
total: req.body.total
});
res.status(201).json(order);
});
// PUT /orders/:id - Update entire order
app.put('/orders/:id', async (req, res) => {
const order = await db.orders.update(req.params.id, req.body);
res.json(order);
});
// PATCH /orders/:id - Partial update
app.patch('/orders/:id', async (req, res) => {
const order = await db.orders.updatePartial(req.params.id, req.body);
res.json(order);
});
// DELETE /orders/:id - Delete order
app.delete('/orders/:id', async (req, res) => {
await db.orders.delete(req.params.id);
res.status(204).send();
});
// Client usage
const response = await fetch('https://api.example.com/users/123');
const user = await response.json();
const ordersResponse = await fetch(`https://api.example.com/users/${user.id}/orders`);
const orders = await ordersResponse.json();
// Problem: 2 requests (N+1 problem for multiple users)
Where REST wins: universal support in every language and framework, HTTP caching for free at the CDN and browser, debugging with cURL, stateless scaling, and mature tooling around OpenAPI and Postman.
Where REST hurts: over-fetching when you only need three fields, under-fetching when one screen needs five round-trips, N+1 problems, painful versioning, and no built-in real-time story.
GraphQL
Query language with client-driven data fetching:
// GraphQL Schema Definition
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
createdAt: DateTime!
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
user: User!
createdAt: DateTime!
}
type OrderItem {
id: ID!
product: Product!
quantity: Int!
price: Float!
}
type Product {
id: ID!
name: String!
price: Float!
}
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
}
type Query {
user(id: ID!): User
orders(status: OrderStatus, limit: Int): [Order!]!
}
type Mutation {
createOrder(input: CreateOrderInput!): Order!
updateOrderStatus(id: ID!, status: OrderStatus!): Order!
}
input CreateOrderInput {
userId: ID!
items: [OrderItemInput!]!
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
`;
// Resolvers
const resolvers = {
Query: {
user: async (_parent, { id }, { dataSources }) => {
return dataSources.userAPI.getUser(id);
},
orders: async (_parent, { status, limit }, { dataSources }) => {
return dataSources.orderAPI.getOrders({ status, limit });
}
},
User: {
orders: async (user, _args, { dataSources }) => {
// DataLoader batches requests automatically
return dataSources.orderAPI.getOrdersByUserId(user.id);
}
},
Order: {
user: async (order, _args, { dataSources }) => {
return dataSources.userAPI.getUser(order.userId);
},
items: async (order, _args, { dataSources }) => {
return dataSources.orderAPI.getOrderItems(order.id);
}
},
Mutation: {
createOrder: async (_parent, { input }, { dataSources }) => {
return dataSources.orderAPI.createOrder(input);
}
}
};
// Client usage - fetch exactly what you need
const query = gql`
query GetUserWithOrders($userId: ID!) {
user(id: $userId) {
name
email
orders {
id
total
status
items {
product {
name
}
quantity
price
}
}
}
}
`;
const { data } = await client.query({ query, variables: { userId: '123' } });
// Single request, gets all related data with no over-fetching
Where GraphQL wins: no over-fetching since the client picks fields, no under-fetching since one query can pull related data, a strongly typed schema, free introspection for docs, and subscriptions for real-time updates.
Where GraphQL hurts: caching is genuinely hard, query complexity attacks are a real concern, the learning curve is real, simple CRUD APIs feel over-engineered, and the tooling has more moving parts than REST.
gRPC (Google Remote Procedure Call)
High-performance RPC framework using Protocol Buffers:
// user.proto - Protocol Buffer definition
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
rpc GetUserOrders (GetUserOrdersRequest) returns (GetUserOrdersResponse);
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
rpc StreamOrderUpdates (StreamOrderUpdatesRequest) returns (stream OrderUpdate);
}
message GetUserRequest {
string user_id = 1;
}
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
message GetUserOrdersRequest {
string user_id = 1;
optional OrderStatus status = 2;
int32 limit = 3;
}
message GetUserOrdersResponse {
repeated Order orders = 1;
}
message Order {
string id = 1;
string user_id = 2;
double total = 3;
OrderStatus status = 4;
repeated OrderItem items = 5;
int64 created_at = 6;
}
message OrderItem {
string id = 1;
string product_id = 2;
string product_name = 3;
int32 quantity = 4;
double price = 5;
}
enum OrderStatus {
PENDING = 0;
PAID = 1;
SHIPPED = 2;
DELIVERED = 3;
}
message CreateOrderRequest {
string user_id = 1;
repeated OrderItemInput items = 2;
}
message OrderItemInput {
string product_id = 1;
int32 quantity = 2;
}
message CreateOrderResponse {
Order order = 1;
}
message StreamOrderUpdatesRequest {
string user_id = 1;
}
message OrderUpdate {
string order_id = 1;
OrderStatus status = 2;
int64 updated_at = 3;
}
// Server implementation
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
const packageDefinition = protoLoader.loadSync('user.proto');
const userProto = grpc.loadPackageDefinition(packageDefinition).user;
class UserService {
async getUser(call: any, callback: any) {
const userId = call.request.user_id;
const user = await db.users.findById(userId);
callback(null, {
id: user.id,
name: user.name,
email: user.email,
created_at: user.createdAt.getTime()
});
}
async getUserOrders(call: any, callback: any) {
const { user_id, status, limit } = call.request;
const orders = await db.orders.findByUserId(user_id, { status, limit });
callback(null, {
orders: orders.map(o => ({
id: o.id,
user_id: o.userId,
total: o.total,
status: o.status,
items: o.items.map(i => ({
id: i.id,
product_id: i.productId,
product_name: i.productName,
quantity: i.quantity,
price: i.price
})),
created_at: o.createdAt.getTime()
}))
});
}
async streamOrderUpdates(call: any) {
const userId = call.request.user_id;
// Subscribe to order updates
const subscription = orderUpdateEmitter.on('update', (order) => {
if (order.userId === userId) {
call.write({
order_id: order.id,
status: order.status,
updated_at: Date.now()
});
}
});
call.on('cancelled', () => {
subscription.unsubscribe();
});
}
}
// Start server
const server = new grpc.Server();
server.addService(userProto.UserService.service, new UserService());
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
server.start();
console.log('gRPC server running on port 50051');
});
// Client usage
const client = new userProto.UserService(
'localhost:50051',
grpc.credentials.createInsecure()
);
// Unary call
client.getUser({ user_id: '123' }, (error, response) => {
console.log('User:', response);
});
// Streaming call
const stream = client.streamOrderUpdates({ user_id: '123' });
stream.on('data', (update) => {
console.log('Order update:', update);
});
Where gRPC wins: roughly 10x faster than REST thanks to the binary protocol, native bidirectional streaming, generated type-safe clients, protobuf payloads that are smaller than JSON, and HTTP/2 multiplexing.
Where gRPC hurts: browsers can't speak it directly (you need grpc-web and a proxy), debugging binary frames is harder than reading JSON in DevTools, no HTTP caching semantics, a learning curve for protobuf, and nothing about the wire format is human-readable.
tRPC (TypeScript RPC)
Type-safe RPC for TypeScript full-stack apps:
// Server - tRPC router definition
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const appRouter = t.router({
// Query: getUser
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.users.findById(input.id);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt
};
}),
// Query: getUserOrders
getUserOrders: t.procedure
.input(z.object({
userId: z.string(),
status: z.enum(['PENDING', 'PAID', 'SHIPPED']).optional(),
limit: z.number().optional()
}))
.query(async ({ input }) => {
const orders = await db.orders.findByUserId(input.userId, {
status: input.status,
limit: input.limit
});
return orders.map(o => ({
id: o.id,
total: o.total,
status: o.status,
items: o.items.map(i => ({
productId: i.productId,
productName: i.productName,
quantity: i.quantity,
price: i.price
})),
createdAt: o.createdAt
}));
}),
// Mutation: createOrder
createOrder: t.procedure
.input(z.object({
userId: z.string(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive()
}))
}))
.mutation(async ({ input }) => {
const order = await db.orders.create({
userId: input.userId,
items: input.items
});
return order;
}),
// Subscription: watchOrderStatus
watchOrderStatus: t.procedure
.input(z.object({ orderId: z.string() }))
.subscription(async function* ({ input }) {
const emitter = orderStatusEmitter;
// Yield initial status
const order = await db.orders.findById(input.orderId);
yield { status: order.status, updatedAt: order.updatedAt };
// Stream updates
const listener = (update: any) => {
if (update.orderId === input.orderId) {
return { status: update.status, updatedAt: update.updatedAt };
}
};
emitter.on('update', listener);
// Cleanup
return () => {
emitter.off('update', listener);
};
})
});
export type AppRouter = typeof appRouter;
// Export router for client
export { appRouter };
// Client - fully type-safe, no code generation
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
// Fully typed queries - autocomplete works!
const user = await client.getUser.query({ id: '123' });
// user is typed as: { id: string; name: string; email: string; createdAt: Date }
const orders = await client.getUserOrders.query({
userId: '123',
status: 'PAID', // TypeScript ensures valid status
limit: 10
});
// orders is typed as: Array<{ id: string; total: number; status: string; ... }>
// Mutations are also typed
const newOrder = await client.createOrder.mutate({
userId: '123',
items: [
{ productId: 'prod-1', quantity: 2 }
]
});
// Real-time subscriptions
const subscription = client.watchOrderStatus.subscribe(
{ orderId: 'ord-123' },
{
onData(data) {
console.log('Order status:', data.status);
// data is fully typed
}
}
);
// React Query integration
import { createTRPCReact } from '@trpc/react-query';
const trpc = createTRPCReact<AppRouter>();
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = trpc.getUser.useQuery({ id: userId });
const { data: orders } = trpc.getUserOrders.useQuery({ userId });
const createOrderMutation = trpc.createOrder.useMutation({
onSuccess: () => {
// Invalidate and refetch
trpc.getUserOrders.invalidate();
}
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h1>{user.name}</h1> {/* Fully typed */}
<p>{user.email}</p>
<h2>Orders</h2>
{orders?.map(order => (
<div key={order.id}>
${order.total} - {order.status}
</div>
))}
</div>
);
}
Where tRPC wins: end-to-end type safety without code generation, instant autocomplete across the boundary, automatic request batching, first-class React Query integration, and zero runtime overhead.
Where tRPC hurts: TypeScript only, no story for public APIs or external clients, a smaller ecosystem than the others, and it really only fits monorepo or same-language full-stack work.
The 2025 decision matrix
| Factor | REST | GraphQL | gRPC | tRPC | Winner |
|---|---|---|---|---|---|
| Public API | ✅ Best | ✅ Good | ❌ Poor | ❌ No | REST |
| Mobile App | ⚠️ OK | ✅ Best | ⚠️ OK | ❌ No | GraphQL |
| Microservices (Internal) | ⚠️ OK | ❌ Overhead | ✅ Best | ❌ No | gRPC |
| Full-stack TypeScript | ⚠️ OK | ⚠️ OK | ❌ Overkill | ✅ Best | tRPC |
| Performance (Throughput) | 10K req/s | 8K req/s | 100K req/s | 12K req/s | gRPC |
| Performance (Latency) | 50ms | 60ms | 5ms | 45ms | gRPC |
| Browser Support | ✅ Native | ✅ Native | ⚠️ grpc-web | ✅ Native | REST/GraphQL |
| Caching | ✅ HTTP | ❌ Complex | ❌ None | ⚠️ Manual | REST |
| Real-time | ❌ No | ✅ Subscriptions | ✅ Streaming | ✅ Subscriptions | GraphQL/gRPC |
| Type Safety | ⚠️ Manual | ⚠️ Codegen | ✅ Codegen | ✅ Native | tRPC/gRPC |
| Learning Curve | Low | Medium | High | Low | REST/tRPC |
| Debugging | ✅ Easy | ⚠️ Medium | ❌ Hard | ✅ Easy | REST/tRPC |
| Multi-language | ✅ All | ✅ Most | ✅ All | ❌ TS only | REST/gRPC |
| Payload Size | 1.0x | 0.9x | 0.3x | 0.95x | gRPC |
| Ecosystem Maturity | ✅ Mature | ✅ Mature | ⚠️ Growing | ⚠️ New | REST/GraphQL |
How to pick
Reach for REST when you're building a public API for external developers, need universal client compatibility, depend on HTTP caching, have simple CRUD operations, or have a team that hasn't worked with alternatives. Multi-language clients also push you toward REST by default.
Reach for GraphQL when mobile bandwidth is a real constraint, when the data is deeply nested, when clients legitimately need flexible queries, when over-fetching is hurting page-load metrics, when you need real-time updates, or when several client types each want a different slice of the same data.
Reach for gRPC for internal microservices (often paired with event-driven Kafka patterns for async flows), for performance-critical paths above 10K req/s per instance, when you need bidirectional streaming, when cross-service type safety is non-negotiable, when latency budgets are under 10ms, or when you're already running a service mesh like Istio or Linkerd.
Reach for tRPC inside a TypeScript monorepo (Next.js, Remix), when one full-stack team owns both ends, when you want type safety without code generation, when you're building internal tools, when you're already invested in React Query, and when there are no external API consumers to worry about.
What the numbers actually say
Benchmark numbers vary wildly depending on payload shape, network topology, and what each implementation is doing under the hood. The numbers below are from a synthetic workload (1000 users, each with 10 orders, all four implementations on the same host) and they line up with what I see in real production traffic. Treat them as relative, not absolute. The directional story is the part that matters.
A few things worth calling out before reading the table. REST with a naive N+1 pattern is roughly 10x slower than the same REST endpoint done right, which is the most common "REST is slow" mistake. gRPC's latency win comes mostly from the binary wire format and HTTP/2 multiplexing, not magic, so the gap shrinks if you put it behind a JSON-translating proxy. GraphQL with DataLoader closes most of the gap to REST while keeping the client benefits, but only if you actually wire DataLoader in. And tRPC sits close to REST on raw throughput, with its real wins on developer velocity rather than wire performance.
// Benchmark setup: 1000 users, each with 10 orders
interface BenchmarkResult {
pattern: string;
requestsPerSecond: number;
avgLatency: number;
p99Latency: number;
payloadSize: number;
cpuUsage: number;
}
const benchmarks: BenchmarkResult[] = [
{
pattern: 'REST',
requestsPerSecond: 10000,
avgLatency: 50,
p99Latency: 150,
payloadSize: 1200, // bytes
cpuUsage: 45 // percent
},
{
pattern: 'REST (with N+1)',
requestsPerSecond: 1000, // 10x slower due to N+1
avgLatency: 500,
p99Latency: 2000,
payloadSize: 13200, // 11 requests * 1200 bytes
cpuUsage: 60
},
{
pattern: 'GraphQL',
requestsPerSecond: 8000,
avgLatency: 60,
p99Latency: 180,
payloadSize: 1000, // Only requested fields
cpuUsage: 50 // Resolver overhead
},
{
pattern: 'GraphQL (with DataLoader)',
requestsPerSecond: 9000,
avgLatency: 55,
p99Latency: 165,
payloadSize: 1000,
cpuUsage: 48
},
{
pattern: 'gRPC',
requestsPerSecond: 100000, // 10x faster
avgLatency: 5,
p99Latency: 15,
payloadSize: 350, // Binary protobuf
cpuUsage: 30 // More efficient
},
{
pattern: 'tRPC',
requestsPerSecond: 12000,
avgLatency: 45,
p99Latency: 135,
payloadSize: 1100,
cpuUsage: 42 // Request batching helps
}
];
// Real-world example: Netflix architecture
const netflixAPIUsage = {
publicAPI: 'REST', // For 3rd party integrations
mobileApps: 'GraphQL', // iOS, Android apps
internalServices: 'gRPC', // 8000+ microservices
adminTools: 'REST', // Would be tRPC if rewritten today
stats: {
grpcServices: 8000,
grpcRequestsPerDay: 50_000_000_000, // 50 billion
avgGrpcLatency: 3, // ms
bandwidthSavings: '65%' // gRPC vs REST
}
};
Migration paths that actually work
REST to GraphQL
// Phase 1: Add GraphQL layer on top of REST services (Backend for Frontend)
import { ApolloServer } from '@apollo/server';
import { RESTDataSource } from '@apollo/datasource-rest';
// Wrap existing REST APIs
class UserAPI extends RESTDataSource {
override baseURL = 'https://api.example.com/';
async getUser(id: string) {
return this.get(`users/${id}`);
}
async getUserOrders(userId: string) {
return this.get(`users/${userId}/orders`);
}
}
// GraphQL resolvers call REST APIs
const resolvers = {
Query: {
user: (_parent, { id }, { dataSources }) => {
return dataSources.userAPI.getUser(id);
}
},
User: {
orders: (user, _args, { dataSources }) => {
return dataSources.userAPI.getUserOrders(user.id);
}
}
};
// Phase 2: Gradually replace REST calls with direct database access
const optimizedResolvers = {
User: {
orders: async (user, _args, { dataSources }) => {
// Direct database query (faster than REST)
return dataSources.db.orders.findByUserId(user.id);
}
}
};
// Phase 3: Deprecate REST endpoints once GraphQL is primary
// Use @deprecated directive
const schema = gql`
type Query {
user(id: ID!): User
# Deprecated: Use 'user' query with 'orders' field
userOrders(userId: ID!): [Order!]! @deprecated(reason: "Use user.orders instead")
}
`;
REST to gRPC (internal services)
// Phase 1: Add gRPC alongside REST
// Keep REST for external clients
app.get('/users/:id', async (req, res) => {
const user = await getUserById(req.params.id);
res.json(user);
});
// Add gRPC for internal services
class UserService {
async getUser(call, callback) {
const user = await getUserById(call.request.user_id);
callback(null, user);
}
}
// Phase 2: Migrate internal services to gRPC client
// Before: Service A calls Service B via REST
const response = await fetch('http://service-b/users/123');
const user = await response.json();
// After: Service A calls Service B via gRPC
const user = await grpcClient.getUser({ user_id: '123' });
// Phase 3: Remove REST from internal services
// Keep REST only for public API gateway
/*
┌──────────────┐
│ API Gateway │ ◄── REST (public clients)
└──────┬───────┘
│
│ gRPC (internal)
│
┌──────▼───────┐ ┌──────────────┐
│ Service A │◄───►│ Service B │
└──────────────┘ └──────────────┘
gRPC
*/
REST to tRPC (full-stack TypeScript)
// Phase 1: Create tRPC router wrapping existing REST handlers
// Existing REST handler
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
});
// Wrap in tRPC router
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Reuse existing logic
return db.users.findById(input.id);
})
});
// Phase 2: Migrate frontend to tRPC client
// Before: REST fetch
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
// After: tRPC query (fully typed)
const user = await trpc.getUser.query({ id });
// Phase 3: Remove REST endpoints
// All clients using tRPC, can remove REST
Stacking patterns together
The single most useful thing I can tell anyone reading this is that the patterns are not exclusive. The right architecture for a real product usually layers two or three of them, with each one chosen for a specific consumer of the API. A public REST surface in front of a gRPC service mesh is one of the most common shapes in production. GraphQL as a BFF (backend for frontend) over the same gRPC mesh is another. tRPC inside a Next.js app for the admin console, talking to the same internal services, is a third.
The diagram below is roughly what that looks like in practice. The interesting layer is not any single protocol, it's the gateway that translates between them, lets each client pick the protocol that fits, and keeps the internal service surface uniform.
// Real-world architecture: Use different patterns for different needs
/*
┌─────────────────────────────────────────────────────────┐
│ API Gateway │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ REST │ │ GraphQL │ │ gRPC-Web │ │
│ │ (Public) │ │ (Mobile) │ │ (Browser)│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼────────────┼─────────────┼───────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────┐
│ Internal Services (gRPC) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ User │◄─┤ Order │◄─┤ Payment │ │
│ │ Service │ │ Service │ │ Service │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└────────────────────────────────────────────────────────┘
// API Gateway routing
export class APIGateway {
// REST for public API
@Get('/api/users/:id')
async getUserREST(@Param('id') id: string) {
const user = await this.grpcUserClient.getUser({ user_id: id });
return this.toRESTFormat(user);
}
// GraphQL for mobile
@Query(() => User)
async user(@Args('id') id: string) {
const user = await this.grpcUserClient.getUser({ user_id: id });
return this.toGraphQLFormat(user);
}
// gRPC-Web for browser (via Envoy proxy)
// Handled by Envoy, not application code
}
// Internal services use gRPC
export class OrderService {
async createOrder(call, callback) {
// Call user service via gRPC
const user = await this.userServiceClient.getUser({
user_id: call.request.user_id
});
// Call payment service via gRPC
const payment = await this.paymentServiceClient.authorizePayment({
user_id: user.id,
amount: call.request.total
});
const order = await this.db.orders.create({...});
callback(null, order);
}
}
// Admin tools use tRPC (same codebase)
const adminRouter = t.router({
getAllOrders: t.procedure
.input(z.object({ limit: z.number().optional() }))
.query(async ({ input }) => {
// Direct database access for admin
return db.orders.findAll({ limit: input.limit || 100 });
})
});
Things that pay off in production
Picking a pattern is the easy part. Operating it well over time is what separates the teams that ship from the ones that quietly migrate away two years later. The notes below are the practices I see consistently pay off in production on each of the four. They're not exhaustive. They're the ones I'd push hard on in a code review.
REST
// 1. Use proper HTTP methods and status codes
app.post('/orders', async (req, res) => {
const order = await createOrder(req.body);
res.status(201).location(`/orders/${order.id}`).json(order);
});
// 2. Implement HATEOAS (links to related resources)
app.get('/orders/:id', async (req, res) => {
const order = await getOrder(req.params.id);
res.json({
...order,
_links: {
self: { href: `/orders/${order.id}` },
user: { href: `/users/${order.userId}` },
cancel: { href: `/orders/${order.id}/cancel`, method: 'POST' }
}
});
});
// 3. Use ETags for caching
app.get('/users/:id', async (req, res) => {
const user = await getUser(req.params.id);
const etag = generateETag(user);
if (req.headers['if-none-match'] === etag) {
return res.status(304).send(); // Not Modified
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'max-age=60');
res.json(user);
});
// 4. Implement rate limiting
const rateLimit = require('express-rate-limit');
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
}));
// 5. Version your API
app.get('/v1/users/:id', handleV1);
app.get('/v2/users/:id', handleV2);
GraphQL
// 1. Use DataLoader to avoid N+1 queries
import DataLoader from 'dataloader';
const userLoader = new DataLoader(async (userIds) => {
const users = await db.users.findByIds(userIds);
return userIds.map(id => users.find(u => u.id === id));
});
const resolvers = {
Order: {
user: (order) => userLoader.load(order.userId) // Batched
}
};
// 2. Implement query complexity limits
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
schema,
validationRules: [createComplexityLimitRule(1000)]
});
// 3. Use persisted queries
const server = new ApolloServer({
persistedQueries: {
cache: new RedisCache({
host: 'redis',
port: 6379
})
}
});
// 4. Add pagination
const resolvers = {
Query: {
orders: async (_, { first, after }) => {
const edges = await db.orders.find({ after, limit: first + 1 });
const hasNextPage = edges.length > first;
return {
edges: edges.slice(0, first).map(node => ({
cursor: encodeCursor(node.id),
node
})),
pageInfo: {
hasNextPage,
endCursor: hasNextPage ? encodeCursor(edges[first - 1].id) : null
}
};
}
}
};
// 5. Implement field-level authorization
const resolvers = {
User: {
email: (user, _, { user: currentUser }) => {
if (user.id !== currentUser.id && !currentUser.isAdmin) {
throw new Error('Unauthorized');
}
return user.email;
}
}
};
gRPC
// 1. Use streaming for large datasets
async function* streamUsers(request) {
const users = db.users.stream(); // Database stream
for await (const user of users) {
yield { user };
}
}
// 2. Implement retries with exponential backoff
const client = new UserServiceClient('localhost:50051', {
'grpc.max_reconnect_backoff_ms': 10000,
'grpc.initial_reconnect_backoff_ms': 1000
});
// 3. Use metadata for authentication
const metadata = new grpc.Metadata();
metadata.add('authorization', `Bearer ${token}`);
client.getUser({ user_id: '123' }, metadata, (error, response) => {
// ...
});
// 4. Implement health checks
service Health {
rpc Check (HealthCheckRequest) returns (HealthCheckResponse);
}
// 5. Use interceptors for cross-cutting concerns
const loggingInterceptor = (options, nextCall) => {
return new grpc.InterceptingCall(nextCall(options), {
start: (metadata, listener, next) => {
console.log('gRPC call started:', options.method_definition.path);
next(metadata, listener);
}
});
};
tRPC
// 1. Use Zod for input validation
const appRouter = t.router({
createOrder: t.procedure
.input(z.object({
userId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().positive().int()
})).min(1)
}))
.mutation(async ({ input }) => {
// Input is validated automatically
return createOrder(input);
})
});
// 2. Implement context for authentication
const t = initTRPC.context<{ user: User | null }>().create();
const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { user: ctx.user } });
});
// 3. Use request batching
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/trpc',
maxBatchSize: 10 // Batch up to 10 requests
})
]
});
// 4. Implement caching with React Query
const { data } = trpc.getUser.useQuery(
{ id: '123' },
{
staleTime: 60 * 1000, // Cache for 1 minute
cacheTime: 5 * 60 * 1000 // Keep in cache for 5 minutes
}
);
// 5. Type-safe error handling
const createOrderMutation = trpc.createOrder.useMutation({
onError: (error) => {
if (error.data?.code === 'INSUFFICIENT_STOCK') {
// TypeScript knows error structure
showNotification('Out of stock');
}
}
});
The honest summary
The right API pattern in 2025 is context-dependent, and most teams that ship cleanly use more than one. REST stays the default for public APIs because of HTTP caching and universal client support. GraphQL pays for itself on mobile, where bandwidth and flexible queries genuinely matter. gRPC is the right pick for internal microservices that need throughput and tight type contracts. tRPC is the option that makes a TypeScript monorepo feel like one application instead of two.
Netflix runs REST for public APIs, GraphQL for mobile, and gRPC for the 8000-plus internal services. That layered shape is what I see at companies past the early-stage. Start with REST when you don't know yet, move parts of the surface to GraphQL when mobile clients start hurting, add gRPC for the hot internal paths, and consider tRPC if you're a TypeScript-only shop. The trap is picking one pattern out of dogma and forcing the rest of the system through it.
Starting points
- Audit current APIs for the obvious wins. Where is over-fetching biting you? Where are the N+1 queries?
- Benchmark the alternatives in your own environment, not on someone else's blog post.
- Migrate one endpoint or one service to a new pattern. Pick the one that hurts the most today.
- Measure latency, payload size, and how much faster the team ships against that surface.
- Expand what works. Roll back what doesn't.
- Document why each pattern was chosen, so the next person doesn't rip it out for the wrong reason.