HTTP Protocols Face-Off: REST vs GraphQL vs gRPC vs tRPC
There's no single "best" API protocol — only the best one for your use case.
If you've ever found yourself in a debate about REST vs GraphQL, or wondered what gRPC and tRPC actually are, this is the guide that settles it. We'll go deep into each protocol — how it works, when it shines, when it falls flat — and give you a concrete framework for picking the right one.
No hype, no bias. Just engineering trade-offs.
Table of Contents
- A Brief History of API Protocols
- REST — The Industry Standard
- GraphQL — Query What You Need
- gRPC — High-Performance RPC
- tRPC — End-to-End Type Safety
- WebSockets, SSE & Webhooks
- Head-to-Head Comparison
- Real-World Usage
- Decision Framework
- Mixing Protocols — API Gateway & BFF Patterns
- Common Mistakes
A Brief History of API Protocols
1990s 2000s 2010s 2020s
│ │ │ │
▼ ▼ ▼ ▼
CORBA/ SOAP/XML REST becomes GraphQL, gRPC,
DCOM (verbose, the standard tRPC rise.
(binary, WS-* specs) (JSON, HTTP) Specialization
complex) era begins.
│ │ │ │
└──────────────┴───────────────┴───────────────┘
Trend: Simpler, faster, more typed
- 1990s: CORBA, DCOM — binary RPC protocols, very complex, language-specific
- Early 2000s: SOAP — XML-based, with WSDLs and heavyweight tooling. Enterprise loved it, developers hated it.
- 2000: Roy Fielding defines REST in his PhD dissertation
- 2010s: REST + JSON becomes the universal standard. Simple, stateless, works everywhere.
- 2012: gRPC (originally Stubby) at Google — high-performance RPC using Protocol Buffers
- 2015: Facebook open-sources GraphQL — query exactly what you need
- 2020s: tRPC emerges — end-to-end type safety for TypeScript stacks. The "no schema" API.
We're now in an era where different protocols solve different problems. Let's break each one down.
REST — The Industry Standard
REST (Representational State Transfer) is an architectural style for designing networked applications. It uses standard HTTP methods to perform CRUD operations on resources.
Core Concepts
Resource: /api/users/123 (a specific user)
Collection: /api/users (all users)
Nested: /api/users/123/posts (posts by user 123)
HTTP Methods:
GET → Read a resource
POST → Create a resource
PUT → Replace a resource entirely
PATCH → Partially update a resource
DELETE → Remove a resource
HTTP Status Codes (The Important Ones)
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid input / validation error |
| 401 | Unauthorized | Not authenticated |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource / state conflict |
| 422 | Unprocessable Entity | Semantic validation error |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Server Error | Something broke on the server |
HATEOAS (The Part Everyone Ignores)
REST's full vision includes HATEOAS — Hypermedia As The Engine Of Application State. The idea: API responses include links to related actions, so clients don't need to hardcode URLs.
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"_links": {
"self": { "href": "/api/users/123" },
"posts": { "href": "/api/users/123/posts" },
"update": { "href": "/api/users/123", "method": "PATCH" },
"delete": { "href": "/api/users/123", "method": "DELETE" }
}
}
In practice, almost nobody implements true HATEOAS. Most "REST" APIs are really just HTTP JSON APIs — and that's fine.
Code Example — Express.js REST API
const express = require('express');
const app = express();
app.use(express.json());
// In-memory store for demo
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
// GET /api/users — List all users
app.get('/api/users', (req, res) => {
const { page = 1, limit = 10 } = req.query;
const start = (page - 1) * limit;
const paginated = users.slice(start, start + Number(limit));
res.json({
data: paginated,
meta: { page: Number(page), limit: Number(limit), total: users.length },
});
});
// GET /api/users/:id — Get a specific user
app.get('/api/users/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST /api/users — Create a user
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
const newUser = { id: users.length + 1, name, email };
users.push(newUser);
res.status(201).json(newUser);
});
// PATCH /api/users/:id — Update a user
app.patch('/api/users/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
Object.assign(user, req.body);
res.json(user);
});
// DELETE /api/users/:id — Delete a user
app.delete('/api/users/:id', (req, res) => {
users = users.filter((u) => u.id !== Number(req.params.id));
res.status(204).end();
});
app.listen(3000, () => console.log('REST API on port 3000'));
Pros and Cons
| Pros | Cons |
|---|---|
| Simple, universally understood | Over-fetching / under-fetching |
| Works with any language/platform | Multiple round-trips for related data |
| Cacheable by default (HTTP caching) | No built-in type system |
| Great tooling (Postman, Swagger, etc.) | API versioning can get messy |
| Stateless — easy to scale | Can lead to endpoint explosion |
When to Use REST
- Public APIs that need to be consumed by anyone
- Simple CRUD applications
- When HTTP caching is important
- When you need maximum compatibility
- When your team knows REST well and the use case doesn't demand anything fancier
GraphQL — Query What You Need
GraphQL is a query language for APIs developed by Facebook in 2012 (open-sourced in 2015). Instead of multiple endpoints, you have a single endpoint where clients specify exactly what data they want.
Core Concepts
REST approach (3 requests):
GET /api/user/123 → { id, name, email, bio, avatar, ... }
GET /api/user/123/posts → [{ id, title, body, ... }, ...]
GET /api/user/123/followers → [{ id, name, ... }, ...]
GraphQL approach (1 request):
POST /graphql
{
user(id: 123) {
name
posts(limit: 5) {
title
}
followersCount
}
}
The client gets exactly the fields it needs — no over-fetching, no under-fetching.
Schema Definition
GraphQL is strongly typed. You define your schema, and everything is validated against it.
# schema.graphql
type User {
id: ID!
name: String!
email: String!
bio: String
posts(limit: Int = 10): [Post!]!
followers: [User!]!
followersCount: Int!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments: [Comment!]!
createdAt: String!
}
type Comment {
id: ID!
text: String!
author: User!
}
type Query {
user(id: ID!): User
users(page: Int, limit: Int): [User!]!
post(id: ID!): Post
}
type Mutation {
createUser(name: String!, email: String!): User!
updateUser(id: ID!, name: String, email: String): User!
deleteUser(id: ID!): Boolean!
createPost(title: String!, body: String!, authorId: ID!): Post!
}
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
Queries, Mutations, and Subscriptions
# Query — read data
query GetUserProfile {
user(id: "123") {
name
email
posts(limit: 5) {
title
createdAt
}
followersCount
}
}
# Mutation — write data
mutation CreatePost {
createPost(title: "Hello World", body: "My first post", authorId: "123") {
id
title
createdAt
}
}
# Subscription — real-time updates
subscription OnNewComment {
commentAdded(postId: "456") {
text
author {
name
}
}
}
Code Example — Apollo Server
const { ApolloServer, gql } = require('@apollo/server');
const { startStandaloneServer } = require('@apollo/server/standalone');
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
}
type Query {
users: [User!]!
user(id: ID!): User
post(id: ID!): Post
}
type Mutation {
createUser(name: String!, email: String!): User!
createPost(title: String!, body: String!, authorId: ID!): Post!
}
`;
// Resolvers — how each field is fetched
const resolvers = {
Query: {
users: () => db.users.findAll(),
user: (_, { id }) => db.users.findById(id),
post: (_, { id }) => db.posts.findById(id),
},
Mutation: {
createUser: (_, { name, email }) => db.users.create({ name, email }),
createPost: (_, { title, body, authorId }) =>
db.posts.create({ title, body, authorId }),
},
// Nested resolvers — this is where GraphQL shines
User: {
posts: (parent) => db.posts.findByAuthorId(parent.id),
},
Post: {
author: (parent) => db.users.findById(parent.authorId),
},
};
const server = new ApolloServer({ typeDefs, resolvers });
startStandaloneServer(server, { listen: { port: 4000 } }).then(({ url }) => {
console.log(`GraphQL server at ${url}`);
});
The N+1 Problem
The biggest performance pitfall in GraphQL. Consider this query:
{
users { # 1 query to get all users
name
posts { # N queries — one per user to get their posts
title
}
}
}
If you have 100 users, this fires 101 database queries (1 for users + 100 for posts). That's the N+1 problem.
Solution: DataLoader
const DataLoader = require('dataloader');
// Batch function — receives an array of IDs, returns results in same order
const postLoader = new DataLoader(async (userIds) => {
const posts = await db.posts.findByAuthorIds(userIds); // Single query
// Group posts by author ID
return userIds.map((id) => posts.filter((p) => p.authorId === id));
});
const resolvers = {
User: {
posts: (parent) => postLoader.load(parent.id), // Batched!
},
};
Now all 100 user-post lookups are batched into a single database query.
Pros and Cons
| Pros | Cons |
|---|---|
| No over-fetching or under-fetching | More complex server implementation |
| Single endpoint, self-documenting | N+1 problem requires DataLoader |
| Strongly typed schema | HTTP caching is harder (everything is POST) |
| Great developer experience (GraphiQL, introspection) | Security: query complexity attacks |
| Subscriptions for real-time data | Steeper learning curve |
| Perfect for mobile (minimize data transfer) | File uploads are awkward |
When to Use GraphQL
- Mobile apps where bandwidth matters
- Complex UIs with varied data needs (dashboards)
- When you have many related entities that clients query in different combinations
- Internal APIs where teams need flexibility
- When multiple frontend apps consume the same API differently
gRPC — High-Performance RPC
gRPC (Google Remote Procedure Call) is a high-performance, open-source RPC framework. It uses Protocol Buffers (protobuf) for serialization and HTTP/2 for transport.
How It Works
REST: gRPC:
JSON over HTTP/1.1 Protobuf (binary) over HTTP/2
Text-based, human-readable Binary, compact, fast
Request-response only Supports streaming
~100-500 bytes for simple obj ~20-50 bytes for same obj
┌──────────┐ protobuf/HTTP/2 ┌──────────┐
│ Client │ ◄──────────────────▶ │ Server │
│ (stub) │ binary, fast │ (impl) │
└──────────┘ └──────────┘
│ │
│ Generated from │ Generated from
│ .proto file │ .proto file
│ │
└──────────┐ ┌─────────────────┘
│ │
▼ ▼
┌──────────────┐
│ .proto file │ ← Single source of truth
│ (schema) │
└──────────────┘
Protocol Buffers (Protobuf)
The schema language for gRPC. Think of it as a strongly typed, language-agnostic data format.
// user.proto
syntax = "proto3";
package user;
service UserService {
// Unary — single request, single response
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
rpc DeleteUser (DeleteUserRequest) returns (Empty);
// Server streaming — client sends one request, server streams responses
rpc ListUsers (ListUsersRequest) returns (stream User);
// Client streaming — client streams requests, server sends one response
rpc UploadUserPhotos (stream Photo) returns (UploadResult);
// Bidirectional streaming — both sides stream
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message User {
string id = 1;
string name = 2;
string email = 3;
int32 age = 4;
repeated string tags = 5;
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
int32 age = 3;
}
message ListUsersRequest {
int32 page = 1;
int32 limit = 2;
}
message Empty {}
Four Communication Patterns
1. Unary RPC (like REST):
Client ──request──▶ Server
Client ◀──response── Server
2. Server Streaming:
Client ──request──▶ Server
Client ◀──response 1── Server
Client ◀──response 2── Server
Client ◀──response N── Server
3. Client Streaming:
Client ──request 1──▶ Server
Client ──request 2──▶ Server
Client ──request N──▶ Server
Client ◀──response── Server
4. Bidirectional Streaming:
Client ──request──▶ Server
Client ◀──response── Server
Client ──request──▶ Server
Client ◀──response── Server
(interleaved, both directions)
Code Example — Node.js gRPC Server
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
// Load protobuf definition
const packageDef = protoLoader.loadSync('user.proto');
const proto = grpc.loadPackageDefinition(packageDef).user;
// In-memory store
const users = new Map();
// Implement service methods
const server = new grpc.Server();
server.addService(proto.UserService.service, {
// Unary RPC
GetUser: (call, callback) => {
const user = users.get(call.request.id);
if (!user) {
return callback({
code: grpc.status.NOT_FOUND,
message: 'User not found',
});
}
callback(null, user);
},
CreateUser: (call, callback) => {
const { name, email, age } = call.request;
const id = `user_${Date.now()}`;
const user = { id, name, email, age };
users.set(id, user);
callback(null, user);
},
// Server streaming
ListUsers: (call) => {
for (const user of users.values()) {
call.write(user); // Stream each user
}
call.end(); // Done streaming
},
// Bidirectional streaming
Chat: (call) => {
call.on('data', (message) => {
console.log(`Received: ${message.text}`);
// Echo back with modification
call.write({ text: `Echo: ${message.text}`, sender: 'server' });
});
call.on('end', () => call.end());
},
});
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
console.log('gRPC server running on port 50051');
});
gRPC Client
const client = new proto.UserService(
'localhost:50051',
grpc.credentials.createInsecure()
);
// Unary call
client.GetUser({ id: 'user_123' }, (err, user) => {
if (err) console.error(err);
else console.log('User:', user);
});
// Server streaming
const stream = client.ListUsers({ page: 1, limit: 10 });
stream.on('data', (user) => console.log('User:', user));
stream.on('end', () => console.log('Done'));
stream.on('error', (err) => console.error(err));
Why HTTP/2 Matters
gRPC uses HTTP/2, which gives it significant advantages over HTTP/1.1:
HTTP/1.1 (REST): HTTP/2 (gRPC):
┌────────────┐ ┌────────────┐
│ Request 1 │──▶ │ Req 1 ──▶ │
│ Response 1 │◀── │ Req 2 ──▶ │ Multiplexed!
│ Request 2 │──▶ Sequential │ Req 3 ──▶ │ All at once
│ Response 2 │◀── │ ◀── Resp 2 │
│ Request 3 │──▶ │ ◀── Resp 1 │
│ Response 3 │◀── │ ◀── Resp 3 │
└────────────┘ └────────────┘
- Multiplexing — Multiple requests/responses over a single TCP connection
- Header compression (HPACK) — Reduces overhead
- Binary framing — More efficient than text-based HTTP/1.1
- Server push — Server can proactively send data
Pros and Cons
| Pros | Cons |
|---|---|
| Extremely fast (binary, HTTP/2) | Not browser-native (needs grpc-web proxy) |
| Strong typing via protobuf | Not human-readable (binary format) |
| Code generation for many languages | Steeper learning curve |
| Built-in streaming support | Harder to debug (no curl) |
| Contract-first development | Limited browser support |
| ~10x smaller payload than JSON | Less tooling than REST |
When to Use gRPC
- Microservice-to-microservice communication (internal)
- High-throughput, low-latency systems
- When you need streaming (real-time data, file transfers)
- Polyglot environments (services in different languages)
- Mobile apps talking to backends (smaller payloads)
tRPC — End-to-End Type Safety
tRPC is a framework for building type-safe APIs in TypeScript without schemas or code generation. Your API types are inferred directly from your server code.
How It Works
Traditional API:
1. Define schema (OpenAPI, GraphQL SDL, protobuf)
2. Generate types / client code
3. Keep schema and implementation in sync (they drift)
4. Runtime validation
tRPC:
1. Write your server code in TypeScript
2. Types are automatically shared with the client
3. No schema, no codegen, no drift
4. Compile-time type checking end-to-end
┌──────────────────────────────────────────────────┐
│ TypeScript Monorepo │
│ │
│ ┌──────────┐ types ┌────────────┐ │
│ │ Server │ ◄─────────────────▶│ Client │ │
│ │ (tRPC │ (inferred, no │ (tRPC │ │
│ │ router) │ codegen) │ client) │ │
│ └──────────┘ └────────────┘ │
│ │
│ Change a server function → client sees it │
│ immediately. Type errors caught at compile time. │
└──────────────────────────────────────────────────┘
Code Example — tRPC Server
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const router = t.router;
const publicProcedure = t.procedure;
// Define your API router
export const appRouter = router({
// Query — read data (like GET)
getUser: publicProcedure
.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', message: 'User not found' });
}
return user;
}),
// List users with pagination
listUsers: publicProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().max(100).default(10),
}))
.query(async ({ input }) => {
return db.users.findMany({
skip: (input.page - 1) * input.limit,
take: input.limit,
});
}),
// Mutation — write data (like POST/PUT/DELETE)
createUser: publicProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return db.users.create({
data: { name: input.name, email: input.email },
});
}),
updateUser: publicProcedure
.input(z.object({
id: z.string(),
name: z.string().min(1).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ input }) => {
return db.users.update({
where: { id: input.id },
data: input,
});
}),
deleteUser: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
await db.users.delete({ where: { id: input.id } });
return { success: true };
}),
});
// Export the router type — this is the magic
export type AppRouter = typeof appRouter;
tRPC Client (React)
// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/trpc'; // Just the TYPE — no runtime import
export const trpc = createTRPCReact<AppRouter>();
// In your React component
function UserProfile({ userId }: { userId: string }) {
// Fully typed! Autocomplete works. Errors caught at compile time.
const { data: user, isLoading } = trpc.getUser.useQuery({ id: userId });
// ^ TypeScript knows the return type
const updateUser = trpc.updateUser.useMutation();
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h1>{user?.name}</h1> {/* TypeScript knows user shape */}
<p>{user?.email}</p>
<button
onClick={() =>
updateUser.mutate({
id: userId,
name: 'New Name',
// email: 123, ← TypeScript ERROR: number not assignable to string
})
}
>
Update
</button>
</div>
);
}
The key insight: change a field name on the server, and every client that uses it gets a compile error immediately. No runtime surprises.
Pros and Cons
| Pros | Cons |
|---|---|
| End-to-end type safety (no drift) | TypeScript only (both server and client) |
| Zero codegen, zero schemas | Not suitable for public APIs |
| Incredible DX (autocomplete, errors) | Requires monorepo or shared types |
| Built on React Query (caching, etc.) | Relatively young ecosystem |
| Tiny bundle size | Not for polyglot backends |
| Easy to learn if you know TypeScript | Couples client and server tightly |
When to Use tRPC
- Full-stack TypeScript apps (Next.js, Remix, etc.)
- Internal tools and admin dashboards
- Startups / small teams where speed matters
- When your entire stack is TypeScript and you want maximum DX
- When you don't need to expose a public API
WebSockets, SSE & Webhooks
These aren't direct alternatives to REST/GraphQL/gRPC, but they solve related problems around real-time communication.
WebSockets
Full-duplex, persistent connection between client and server. Both sides can send messages at any time.
HTTP: Request → Response (one direction at a time)
WebSocket:
Client ◄──────────────────▶ Server
(persistent, bidirectional)
// Server (ws library)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
ws.on('message', (data) => {
// Broadcast to all connected clients
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data.toString());
}
});
});
});
// Client
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (event) => console.log('Received:', event.data);
ws.send('Hello everyone!');
Use for: Chat apps, multiplayer games, collaborative editing, live dashboards.
Server-Sent Events (SSE)
One-way streaming from server to client. Simpler than WebSockets when you only need server-to-client updates.
// Server (Express)
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Send an event every second
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: new Date() })}\n\n`);
}, 1000);
req.on('close', () => clearInterval(interval));
});
// Client
const source = new EventSource('/events');
source.onmessage = (event) => {
console.log('Update:', JSON.parse(event.data));
};
Use for: Live notifications, stock tickers, build logs, progress updates. Simpler than WebSockets, works over standard HTTP, auto-reconnects.
Webhooks
Not a protocol — a pattern. Server A sends an HTTP POST to Server B when something happens.
GitHub webhook example:
1. You configure a webhook URL in GitHub repo settings
2. Someone pushes to main
3. GitHub sends POST to your URL:
{
"event": "push",
"ref": "refs/heads/main",
"commits": [...]
}
4. Your server processes it (deploy, notify, etc.)
Use for: Event-driven integrations (payment notifications, CI/CD triggers, third-party integrations).
Quick Comparison
| WebSockets | SSE | Webhooks | |
|---|---|---|---|
| Direction | Bidirectional | Server → Client | Server → Server |
| Connection | Persistent | Persistent | On-demand |
| Protocol | WS (upgrade from HTTP) | HTTP | HTTP POST |
| Browser Support | Great | Great | N/A (server-side) |
| Use Case | Chat, games, collab | Live feeds, notifications | Integrations, events |
Head-to-Head Comparison
Here's the comparison you've been waiting for:
| Feature | REST | GraphQL | gRPC | tRPC |
|---|---|---|---|---|
| Protocol | HTTP/1.1+ | HTTP/1.1+ | HTTP/2 | HTTP/1.1+ |
| Data Format | JSON | JSON | Protobuf (binary) | JSON |
| Type Safety | Manual (OpenAPI) | Schema-based | Protobuf schema | Full (TypeScript) |
| Code Generation | Optional | Optional | Required | None needed |
| Performance | Good | Good | Excellent | Good |
| Payload Size | Medium | Small (exact fields) | Smallest (binary) | Medium |
| Browser Support | Native | Native | Needs proxy | Native |
| Streaming | SSE/WebSocket | Subscriptions | Native (4 types) | Via subscriptions |
| Caching | Easy (HTTP) | Hard (single POST) | Hard | Via React Query |
| Learning Curve | Low | Medium | High | Low (if you know TS) |
| Tooling | Excellent | Great | Good | Good |
| Public API | Excellent | Good | Poor | Not suitable |
| Microservices | Good | Medium | Excellent | Not suitable |
| File Uploads | Easy | Awkward | Streaming | Via HTTP |
| Real-Time | Polling/SSE | Subscriptions | Streaming | Subscriptions |
| Language Support | Any | Any | Many (codegen) | TypeScript only |
Performance Benchmark (Approximate)
Serialization/Deserialization speed (lower is better):
Protobuf (gRPC) ████ ~0.5ms
JSON (REST) ████████████ ~1.5ms
GraphQL response ████████████ ~1.5ms
(parsing + validation adds overhead)
Payload size for same data (lower is better):
Protobuf ████ ~50 bytes
JSON (GraphQL) ████████████ ~150 bytes
JSON (REST) ████████████████████ ~250 bytes
(REST often over-fetches)
Requests per second (single server, higher is better):
gRPC ████████████████████████ ~50,000 rps
tRPC ████████████████ ~35,000 rps
REST ██████████████ ~30,000 rps
GraphQL ████████████ ~25,000 rps
(GraphQL has resolver overhead)
These are rough estimates — actual numbers depend heavily on your specific workload, serialization complexity, and hardware.
Real-World Usage
Companies don't just pick one protocol — they use the right tool for each job.
| Company | Protocols Used | Why |
|---|---|---|
| gRPC (internal), REST (public) | gRPC for high-perf microservices, REST for public APIs | |
| Netflix | GraphQL + gRPC | GraphQL for UI backends, gRPC between microservices |
| Shopify | GraphQL (public API) | Flexible API for thousands of third-party integrations |
| Slack | REST + WebSockets | REST for CRUD, WebSockets for real-time messaging |
| Uber | gRPC (internal) | Low-latency communication between services |
| Meta | GraphQL + Thrift | GraphQL for frontend, Thrift (similar to gRPC) internal |
| Stripe | REST (public) | Industry-standard API, maximum compatibility |
| Discord | REST + WebSockets | REST for data, WebSockets for real-time voice/chat |
| Vercel / T3 Stack | tRPC | Full-stack TypeScript, maximum DX |
| Cloudflare | REST + gRPC | REST for public API, gRPC internal |
Key Insight
Notice a pattern? Most companies use REST for public APIs and gRPC for internal microservice communication. GraphQL sits in between as a flexible BFF (Backend for Frontend). tRPC is for full-stack TypeScript teams.
Decision Framework
Flowchart
What are you building?
/ | \
/ | \
Public API Internal Full-stack
(3rd parties) microservices TypeScript app
| | |
▼ ▼ ▼
┌───────┐ ┌────────┐ ┌──────────┐
│ REST │ │ gRPC │ │ tRPC │
│ (safe │ │ (fast, │ │ (max DX, │
│ bet) │ │ typed) │ │ no schema│
└───┬───┘ └────────┘ └──────────┘
│
Do clients need
flexible queries?
/ \
Yes No
| |
▼ ▼
┌─────────┐ Stick with
│ GraphQL │ REST
│ (BFF) │
└─────────┘
Need real-time?
│
├─ Bidirectional → WebSockets or gRPC streaming
├─ Server → Client only → SSE
└─ Server → Server events → Webhooks
Quick Decision Matrix
| Scenario | Recommended |
|---|---|
| Public API for external developers | REST |
| Mobile app with varied data needs | GraphQL |
| Internal microservices (polyglot) | gRPC |
| Full-stack TypeScript (Next.js) | tRPC |
| Real-time chat / gaming | WebSockets |
| Live notifications / feeds | SSE or GraphQL subscriptions |
| Payment / integration callbacks | Webhooks |
| High-throughput data pipeline | gRPC streaming |
| Simple CRUD with basic clients | REST |
| E-commerce storefront with many integrations | GraphQL |
Mixing Protocols
In the real world, you rarely use just one protocol. Here are two common patterns.
API Gateway Pattern
A single entry point that routes to different backend services, each potentially using different protocols.
┌──────────────────────────────────────────────────┐
│ Clients │
│ Browser Mobile App Partner API │
└──────┬───────────┬─────────────┬─────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────┐
│ API Gateway │
│ (Kong, AWS API Gateway, Nginx, etc.) │
│ │
│ Routes: │
│ /api/* → REST services │
│ /graphql → GraphQL server │
│ /ws → WebSocket server │
│ │
│ Also handles: Auth, rate limiting, logging │
└──────┬───────────┬─────────────┬─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ REST │ │ GraphQL │ │ gRPC │
│ Service │ │ Server │ │Services │
└─────────┘ └────┬────┘ └─────────┘
│
┌─────┴──────┐
│ Talks gRPC │
│ to internal│
│ services │
└────────────┘
BFF Pattern (Backend for Frontend)
Each client type gets its own backend that aggregates and shapes data from internal services.
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Web App │ │ Mobile │ │ Partner │
│ (React) │ │ App │ │ API │
└─────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Web BFF │ │Mobile BFF│ │ Public │
│ (tRPC or │ │(GraphQL) │ │ REST API │
│ GraphQL)│ │ │ │ │
└─────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────┼─────────────┘
│
▼
┌────────────────┐
│ Internal gRPC │
│ Microservices │
└────────────────┘
- Web BFF uses tRPC or GraphQL — rich, typed queries for complex UIs
- Mobile BFF uses GraphQL — flexible queries, minimal data transfer
- Public API uses REST — universal compatibility for partners
This is how companies like Netflix, Airbnb, and Spotify architect their systems.
Common Mistakes
1. Using GraphQL for Everything
GraphQL is great, but it adds complexity. Don't use it for a simple CRUD app with one client. REST is fine.
2. Choosing gRPC for Browser-Facing APIs
gRPC doesn't work natively in browsers. You need grpc-web, which adds a proxy layer. If your primary client is a browser, use REST or GraphQL.
3. Not Versioning Your REST API
Bad: /api/users ← Breaking changes affect everyone
Good: /api/v1/users ← Clients can migrate at their own pace
Also: Accept: application/vnd.myapp.v2+json ← Header-based versioning
4. Over-fetching in REST When GraphQL Would Help
If your mobile app is making 5 REST calls per screen and discarding 80% of the data, that's a sign you need GraphQL or a BFF.
5. Not Handling the N+1 Problem in GraphQL
If you don't use DataLoader (or an equivalent), your GraphQL API will be slower than REST. Always batch nested queries.
6. Using tRPC for Public APIs
tRPC is designed for TypeScript-to-TypeScript communication. If you need Java, Python, or Go clients, use REST or gRPC.
7. Ignoring Error Handling
// Bad REST error response
res.status(500).json({ error: "Something went wrong" });
// Good REST error response
res.status(422).json({
error: {
code: "VALIDATION_ERROR",
message: "Email is required",
details: [
{ field: "email", message: "Must be a valid email address" }
]
}
});
8. Not Considering Team Expertise
The "best" protocol means nothing if your team doesn't know how to use it. A well-built REST API beats a poorly-implemented GraphQL one every time.
TL;DR
┌──────────────────────────────────────────────────────────────┐
│ API PROTOCOL CHEAT SHEET │
├──────────────────────────────────────────────────────────────┤
│ │
│ REST .......... Universal, simple, cacheable │
│ Best for: Public APIs, simple CRUD │
│ │
│ GraphQL ....... Flexible queries, no over-fetching │
│ Best for: Complex UIs, mobile apps │
│ │
│ gRPC .......... Fast, typed, streaming │
│ Best for: Microservices, high-throughput │
│ │
│ tRPC .......... E2E type safety, zero schema │
│ Best for: Full-stack TypeScript apps │
│ │
│ GOLDEN RULES │
│ ──────────── │
│ 1. Public API? → REST (safe, universal) │
│ 2. Internal services? → gRPC (fast, typed) │
│ 3. Complex UI? → GraphQL (flexible queries) │
│ 4. Full-stack TS? → tRPC (maximum DX) │
│ 5. Real-time? → WebSockets or SSE │
│ 6. You CAN mix them → API Gateway or BFF pattern │
│ │
└──────────────────────────────────────────────────────────────┘
Let's Connect!
If this comparison helped you make better architecture decisions, I'd love to connect! I regularly share deep dives on system design, backend architecture, and web development.
Connect with me on LinkedIn — let's grow together.
Share this with a developer who's still debating REST vs GraphQL!
Top comments (0)