import ComparisonTable from ’../../components/ComparisonTable.astro’;
API design choices affect developer experience, performance, and how easily clients can consume your services. REST, GraphQL, and gRPC each solve different problems — understanding when each excels prevents over-engineering.
Quick Verdict
Choose REST when: Building public APIs, simple CRUD services, or when broad client compatibility matters.
Choose GraphQL when: Client data requirements vary significantly, you have multiple clients (web, mobile, third-party) with different needs, or you’re building a developer API platform.
Choose gRPC when: You need maximum performance for internal service-to-service communication, strong typing across services, or bidirectional streaming.
Comparison Matrix
<ComparisonTable headers={[“Dimension”, “REST”, “GraphQL”, “gRPC”]} rows={[ [“Protocol”, “HTTP/1.1 or HTTP/2”, “HTTP/1.1 or HTTP/2”, “HTTP/2 (required)”], [“Data format”, “JSON (typically)”, “JSON”, “Protocol Buffers (binary)”], [“Schema”, “OpenAPI (optional)”, “SDL (required)”, “Proto files (required)”], [“Versioning”, “URL versioning (v1, v2)”, “Schema evolution”, “Proto field numbers”], [“Browser support”, “Native”, “Native (HTTP)”, “Limited (grpc-web)”], [“Over/under-fetching”, “Common problem”, “Solved”, “Controlled”], [“Real-time”, “SSE or WebSocket”, “Subscriptions”, “Bidirectional streaming”], [“Code generation”, “OpenAPI codegen”, “GraphQL codegen”, “protoc (all languages)”], [“Performance”, “Good”, “Good”, “Best (~10x faster)”], [“Caching”, “HTTP cache native”, “Requires custom”, “No HTTP cache”], [“Learning curve”, “Low”, “Medium”, “High”], ]} />
REST API
REST maps HTTP verbs to CRUD operations on resources:
// Express REST API
import express from 'express';
const router = express.Router();
// GET /users — list users
router.get('/users', async (req, res) => {
const { page = 1, limit = 20, role } = req.query;
const users = await db.users.findAll({
where: role ? { role } : {},
limit: Number(limit),
offset: (Number(page) - 1) * Number(limit),
});
res.json({
data: users,
meta: { page, limit, total: await db.users.count() },
});
});
// GET /users/:id — get single user
router.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(user);
});
// POST /users — create user
router.post('/users', async (req, res) => {
const { name, email, role } = req.body;
const user = await db.users.create({ name, email, role });
res.status(201).json(user);
});
// PATCH /users/:id — partial update
router.patch('/users/:id', async (req, res) => {
const updated = await db.users.update(req.params.id, req.body);
res.json(updated);
});
// DELETE /users/:id — delete
router.delete('/users/:id', async (req, res) => {
await db.users.delete(req.params.id);
res.status(204).send();
});
OpenAPI specification:
# openapi.yaml
openapi: "3.1.0"
info:
title: Users API
version: "1.0"
paths:
/users:
get:
summary: List users
parameters:
- name: page
in: query
schema: { type: integer, default: 1 }
- name: role
in: query
schema: { type: string, enum: [admin, user, viewer] }
responses:
"200":
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/User" }
GraphQL
GraphQL lets clients request exactly what they need:
// GraphQL schema
const typeDefs = `
type User {
id: ID!
name: String!
email: String!
role: UserRole!
posts: [Post!]!
postCount: Int!
}
type Post {
id: ID!
title: String!
author: User!
tags: [String!]!
}
enum UserRole { ADMIN USER VIEWER }
type Query {
user(id: ID!): User
users(role: UserRole, limit: Int = 20, offset: Int = 0): [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
userCreated: User!
}
`;
const resolvers = {
Query: {
user: (_, { id }) => db.users.findById(id),
users: (_, { role, limit, offset }) =>
db.users.findAll({ where: role ? { role } : {}, limit, offset }),
},
User: {
posts: (user) => db.posts.findByAuthorId(user.id),
postCount: (user) => db.posts.countByAuthorId(user.id),
},
Mutation: {
createUser: (_, { input }) => db.users.create(input),
},
};
Client query — ask for exactly what you need:
# Mobile app needs minimal data
query MobileUserList {
users(limit: 10) {
id
name
role
}
}
# Dashboard needs full data
query DashboardUserDetail($id: ID!) {
user(id: $id) {
id
name
email
role
postCount
posts {
id
title
tags
}
}
}
Without GraphQL (REST), different clients either get too much data or require multiple requests to get what they need. GraphQL solves both in one query.
GraphQL solves over-fetching:
REST: GET /users → Returns all 20 fields even if mobile only needs 3
GraphQL: Query only { id, name, role } → Returns exactly 3 fields
GraphQL solves under-fetching:
REST: Need user + their posts + post authors
→ GET /users/:id
→ GET /posts?userId=123
→ GET /users/:authorId (for each post author)
→ 3+ round trips
GraphQL: One query for all nested data → 1 round trip
gRPC
gRPC uses Protocol Buffers for high-performance service communication:
// user.proto
syntax = "proto3";
package user;
service UserService {
// Unary (request/response)
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
// Server streaming (one request, multiple responses)
rpc ListUsers (ListUsersRequest) returns (stream User);
// Client streaming (multiple requests, one response)
rpc BatchCreateUsers (stream CreateUserRequest) returns (BatchCreateResponse);
// Bidirectional streaming
rpc SyncUsers (stream UserSyncRequest) returns (stream UserSyncResponse);
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
UserRole role = 4;
google.protobuf.Timestamp created_at = 5;
}
message GetUserRequest {
int64 id = 1;
}
enum UserRole {
USER_ROLE_UNSPECIFIED = 0;
USER_ROLE_ADMIN = 1;
USER_ROLE_USER = 2;
USER_ROLE_VIEWER = 3;
}
// TypeScript gRPC server implementation
import * as grpc from '@grpc/grpc-js';
import { UserServiceImplementation } from './generated/user_grpc_pb';
const userService: UserServiceImplementation = {
getUser: async (call, callback) => {
const userId = call.request.getId();
const user = await db.users.findById(userId);
if (!user) {
callback({
code: grpc.status.NOT_FOUND,
message: `User ${userId} not found`,
});
return;
}
const response = new User();
response.setId(user.id);
response.setName(user.name);
response.setEmail(user.email);
callback(null, response);
},
listUsers: async (call) => {
const users = await db.users.stream();
for await (const user of users) {
const response = new User();
response.setId(user.id);
response.setName(user.name);
call.write(response);
}
call.end();
},
};
gRPC client (another service):
import * as grpc from '@grpc/grpc-js';
import { UserServiceClient } from './generated/user_grpc_pb';
const client = new UserServiceClient(
'user-service:50051',
grpc.credentials.createInsecure()
);
// Typed call — TypeScript knows exact shape
const request = new GetUserRequest();
request.setId(123);
client.getUser(request, (error, response) => {
if (error) throw error;
console.log(response.getName()); // TypeScript: response is typed User
});
Performance Comparison
| Scenario | REST (JSON) | GraphQL (JSON) | gRPC (Protobuf) |
|---|---|---|---|
| Payload size (same data) | 100% | 100-120% | 30-40% |
| Parse time | 1x | 1.1x | 0.1x |
| Request latency | 1x | 1-1.3x | 0.2-0.5x |
| Throughput | 1x | 0.8-1x | 3-10x |
gRPC’s performance advantage is significant for internal services. For public APIs, the difference rarely matters compared to network latency.
API Gateway Pattern
Many systems use all three:
External clients (browser, mobile)
│
▼
API Gateway (REST or GraphQL)
│
├──→ User Service (gRPC)
├──→ Order Service (gRPC)
├──→ Payment Service (gRPC)
└──→ Notification Service (gRPC)
- External: REST/GraphQL (broad compatibility)
- Internal: gRPC (performance, strong typing)
When to Choose Each
REST:
- Public APIs consumed by many different clients
- Simple CRUD operations
- When HTTP caching is important
- Teams new to API design (simplest to start)
GraphQL:
- Frontend teams need flexibility in data fetching
- Multiple clients (web/mobile/third-party) with different needs
- Developer API platforms (GitHub, Shopify use GraphQL)
- Aggregating data from multiple REST services
gRPC:
- Internal microservice communication
- High-throughput data pipelines
- Real-time bidirectional communication
- When strong typing across service boundaries matters
- IoT and edge devices (binary protocol saves bandwidth)
Bottom Line
REST remains the right default — it’s universal, well-understood, and the tooling is excellent. GraphQL solves real problems for teams with multiple clients and flexible data needs. gRPC is the clear winner for internal service communication where performance and strong typing are priorities. Many mature systems use all three: REST/GraphQL at the edge, gRPC internally.