What are API Paradigms?
APIs (Application Programming Interfaces) enable communication between services. Three dominant paradigms have emerged: REST, GraphQL, and gRPC. Each solves different problems.
Key question: How should services communicate?
Quick Comparison
| Feature | REST | GraphQL | gRPC |
|---|---|---|---|
| Protocol | Commonly HTTP/1.1, HTTP/2 or HTTP/3 | Commonly HTTP; transport is not the schema model | Native gRPC commonly HTTP/2 |
| Format | Any chosen representation, often JSON | Usually JSON responses | Usually Protobuf (binary) |
| Philosophy | Resources | Queries | RPC |
| Performance | Depends on endpoint and transport | Depends on selection and resolver cost | Depends on serialization and service work |
| Caching | Standard HTTP mechanisms if configured | Possible, with query and response design | Application/intermediary design |
| Browser Support | ✅ | ✅ | ❌ (needs proxy) |
| Learning Curve | Low | Medium | High |
| Best For | Public APIs | Mobile/Complex UIs | Microservices |
Scope of the examples
The following snippets demonstrate request shapes, schemas and handler structure. They are not complete deployable services: database methods, authentication and authorization, pagination limits, cancellation, deadlines and error handling must be supplied. The local gRPC sample is intentionally loopback-only and uses plaintext; a deployed service needs an appropriate transport-security configuration. Proto fragments in separate sections are independent alternatives, not one combined file.
REST (Representational State Transfer)
Philosophy
Everything is a resource with a unique URL. Use standard HTTP methods to manipulate resources.
GET /users/123 → Read user POST /users → Create user PUT /users/123 → Update user DELETE /users/123 → Delete user
Example API
// REST endpoint
GET /api/users/123
Response:
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"posts": [
{
"id": 1,
"title": "Hello World",
"comments": [...] // All comments included!
},
...
]
}
Problem: Over-fetching - you get ALL data even if you only need the name.
REST Implementation
// Express.js REST API
const express = require('express');
const app = express();
app.use(express.json());
// GET /users/:id
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(user);
});
// POST /users
app.post('/users', async (req, res) => {
const { name, email } = req.body;
const user = await db.users.create({ name, email });
res.status(201).json(user);
});
// PUT /users/:id
app.put('/users/:id', async (req, res) => {
const user = await db.users.update(req.params.id, req.body);
res.json(user);
});
// DELETE /users/:id
app.delete('/users/:id', async (req, res) => {
await db.users.delete(req.params.id);
res.status(204).send();
});
app.listen(3000);
REST Best Practices
// Versioning GET /api/v1/users/123 GET /api/v2/users/123 // Filtering & Pagination GET /api/users?role=admin&page=2&limit=20 // Nested resources GET /api/users/123/posts GET /api/users/123/posts/456/comments // HTTP Status Codes 200 OK // Success 201 Created // Resource created 204 No Content // Deleted successfully 400 Bad Request // Invalid input 401 Unauthorized // Not authenticated 403 Forbidden // Not authorized 404 Not Found // Resource doesn't exist 500 Internal Error // Server error
Pros & Cons
✅ Pros:
- Simple, well-understood
- Standard HTTP caching when cache keys, headers and authorization rules permit it
- Stateless, scalable
- Great for public APIs
- Tooling everywhere
❌ Cons:
- Over-fetching (getting too much data)
- Under-fetching (need multiple requests)
- Versioning challenges
- Schema and generated client types need an additional contract such as OpenAPI
GraphQL
Philosophy
Client dictates exactly what data it needs. Single endpoint, flexible queries.
# Client query
{
user(id: 123) {
name
email
# Only get first 5 posts with titles
posts(limit: 5) {
title
}
}
}
# Server response (exactly what was asked)
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com",
"posts": [
{ "title": "Hello World" },
{ "title": "GraphQL Guide" }
]
}
}
}
Schema Definition
# schema.graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
text: String!
author: User!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: 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!
}
GraphQL Implementation
// Apollo Server
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const typeDefs = `
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
}
`;
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
return await context.db.users.findById(id);
},
users: async (parent, args, context) => {
return await context.db.users.findAll();
}
},
Mutation: {
createUser: async (parent, { name, email }, context) => {
return await context.db.users.create({ name, email });
}
},
User: {
// Resolver for User.posts field
posts: async (parent, args, context) => {
return await context.db.posts.findByUserId(parent.id);
}
}
};
const server = new ApolloServer({ typeDefs, resolvers });
// database and authenticateRequest are application-specific dependencies.
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => ({
db: database,
user: await authenticateRequest(req)
})
});
console.log(`Server ready at ${url}`);
Advanced Features
Fragments (Reusable queries):
fragment UserInfo on User {
id
name
email
}
query GetUsers {
users {
...UserInfo
posts {
title
}
}
}
Variables:
query GetUser($userId: ID!) {
user(id: $userId) {
name
email
}
}
# Variables
{
"userId": "123"
}
Subscriptions (Real-time):
subscription OnNewPost {
postAdded {
id
title
author {
name
}
}
}
// Server-side subscription
const resolvers = {
Subscription: {
postAdded: {
subscribe: () => pubsub.asyncIterator(['POST_ADDED'])
}
}
};
// Publish event
pubsub.publish('POST_ADDED', {
postAdded: newPost
});
Pros & Cons
✅ Pros:
- Clients select response fields; resolvers can still overfetch internally
- Single request for related data
- Strong typing (schema)
- Perfect for mobile (minimize bandwidth)
- Introspection (self-documenting)
❌ Cons:
- Complex caching (can't use HTTP caching easily)
- N+1 query risk; batching, joins or request-scoped loaders can address it
- Security risks (deeply nested queries can DoS server)
- Harder to version
- Files/uploads awkward
gRPC (Google Remote Procedure Call)
Philosophy
High-performance RPC using binary protocol. Call remote functions like local functions.
Client calls: getUser(123) ↓ gRPC converts to binary → Sent over HTTP/2 ↓ Server executes: getUser(123) ↓ Returns binary response → Converted to object
Protocol Buffers (Protobuf)
// user.proto
syntax = "proto3";
package users;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc CreateUser(CreateUserRequest) returns (User);
rpc StreamUsers(StreamUsersRequest) returns (stream User);
}
message User {
int32 id = 1;
string name = 2;
string email = 3;
repeated Post posts = 4;
}
message Post {
int32 id = 1;
string title = 2;
string content = 3;
}
message GetUserRequest {
int32 id = 1;
}
message ListUsersRequest {
int32 page = 1;
int32 page_size = 2;
}
message ListUsersResponse {
repeated User users = 1;
int32 total = 2;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message StreamUsersRequest {
int32 min_id = 1;
}
gRPC Implementation
Server (Node.js):
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
// Load proto file
const packageDefinition = protoLoader.loadSync('user.proto', { keepCase: true });
const userProto = grpc.loadPackageDefinition(packageDefinition).users;
// Implement service methods
const server = new grpc.Server();
server.addService(userProto.UserService.service, {
getUser: async (call, callback) => {
const userId = call.request.id;
const user = await db.users.findById(userId);
if (!user) {
return callback({
code: grpc.status.NOT_FOUND,
message: 'User not found'
});
}
callback(null, user);
},
listUsers: async (call, callback) => {
const { page, page_size } = call.request;
const users = await db.users.findAll({ page, page_size });
callback(null, { users, total: users.length });
},
createUser: async (call, callback) => {
const { name, email } = call.request;
const user = await db.users.create({ name, email });
callback(null, user);
},
// Server streaming
streamUsers: (call) => {
const stream = db.users.streamFrom(call.request.min_id);
stream.on('data', (user) => {
call.write(user);
});
stream.on('end', () => {
call.end();
});
}
});
server.bindAsync(
'127.0.0.1:50051',
grpc.ServerCredentials.createInsecure(),
() => server.start()
);
Client:
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const packageDefinition = protoLoader.loadSync('user.proto', { keepCase: true });
const userProto = grpc.loadPackageDefinition(packageDefinition).users;
const client = new userProto.UserService(
'localhost:50051',
grpc.credentials.createInsecure()
);
// Unary call
client.getUser({ id: 123 }, (error, user) => {
if (error) {
console.error(error);
} else {
console.log('User:', user);
}
});
// Server streaming
const call = client.streamUsers({ min_id: 100 });
call.on('data', (user) => {
console.log('Received user:', user);
});
call.on('end', () => {
console.log('Stream ended');
});
Four Communication Patterns
service UserService {
// 1. Unary: Single request → Single response
rpc GetUser(GetUserRequest) returns (User);
// 2. Server streaming: Single request → Stream of responses
rpc ListUsers(ListUsersRequest) returns (stream User);
// 3. Client streaming: Stream of requests → Single response
rpc UploadUsers(stream User) returns (UploadResponse);
// 4. Bidirectional streaming: Stream ←→ Stream
rpc Chat(stream Message) returns (stream Message);
}
Pros & Cons
✅ Pros:
- Extremely fast (binary, HTTP/2, multiplexing)
- Type-safe (generated code)
- Streaming support (built-in)
- Multiple languages (code generation)
- Compact binary representation for many schemas; measure against compressed JSON for the actual payload
❌ Cons:
- No browser support (needs envoy/grpc-web proxy)
- Harder to debug (binary, not human-readable)
- Learning curve (Protobuf, code generation)
- Limited middleware ecosystem
Compare the same work, not protocol labels
The previous numerical latency and payload examples had no workload or measurement method and are not benchmarks. A fair comparison returns equivalent data, uses the same backend access pattern and tests both cold and reused connections. Record serialized bytes, compressed wire bytes, CPU, p50/p95 latency and error rate under the same concurrency.
For a profile screen needing a user and twenty posts, count database queries as well as network requests. A single GraphQL operation that triggers twenty-one database queries can be slower than a REST endpoint that performs one join. Conversely, a narrowly selected GraphQL response can avoid transferring unused fields. gRPC can provide generated contracts and streaming, but serialization cannot fix a slow database.
REST and GraphQL can both use HTTP/2. Multiplexing is not exclusive to gRPC. Read GraphQL’s performance guidance and gRPC’s RPC lifecycle documentation for the distinct concerns.
Real-World Usage
REST: Stripe API
curl https://api.stripe.com/v1/charges \ -u sk_test_xxx: \ -d amount=2000 \ -d currency=usd \ -d source=tok_visa
GraphQL: GitHub API
query {
viewer {
repositories(first: 10) {
nodes {
name
stargazers {
totalCount
}
}
}
}
}
gRPC: Google Cloud APIs
// Google Cloud Pub/Sub
service Publisher {
rpc Publish(PublishRequest) returns (PublishResponse);
}
Product names do not define a universal internal architecture. Treat the examples above as publicly documented API styles, not a reconstruction of a company’s private service topology.
When to Use What?
Use REST when:
- Building public APIs
- Need simple HTTP caching
- Wide audience (mobile, web, third-party)
- Team unfamiliar with GraphQL/gRPC
- Examples: Twitter API, Stripe API, AWS API
Use GraphQL when:
- Mobile app (minimize round trips)
- Complex, nested data requirements
- Rapid frontend iteration
- Multiple client types with different needs
- Example: GitHub’s documented GraphQL API
Use gRPC when:
- Internal microservices
- Performance critical (low latency, high throughput)
- Polyglot services (multiple languages)
- Streaming data required
- Example: documented gRPC APIs in Google Cloud
Migration Strategies
REST → GraphQL
// Wrap existing REST endpoints
const resolvers = {
Query: {
user: async (parent, { id }) => {
// Call existing REST API
const response = await fetch(`/api/users/${id}`);
return response.json();
}
}
};
REST → gRPC (Dual Support)
// Expose both REST and gRPC
app.get('/users/:id', restHandler); // REST
server.addService(userService, grpcHandler); // gRPC
// Use envoy proxy for gRPC-web (browser support)
Interview Tips 💡
When discussing API design in system design interviews:
- Identify audience: "For public API, use REST for simplicity and caching..."
- Performance needs: "Internal services need low latency, so gRPC..."
- Client requirements: "Mobile app with complex screens, GraphQL prevents over-fetching..."
- Trade-offs: "gRPC can reduce serialization overhead; browser integration uses a compatible transport such as gRPC-Web..."
- Real examples: "GitHub documents a GraphQL API; use public provider documentation for concrete integration details..."
- Hybrid approach: "Could use REST for public API, gRPC for internal services..."
Worked decision: a product screen and a batch worker
A product team can expose REST for a stable partner integration, GraphQL for a changing aggregate screen and gRPC for a streaming internal worker without treating one as the winner. The cost is maintaining more contracts. Choose the smallest set that resolves an actual client constraint, then test an added field, a deprecated field and an interrupted request across supported client versions.
Related Concepts
- HTTP evolution — Protocol powering gRPC
- API Gateway — Central entry point for APIs
- Load Balancing — Distributing API requests
- Caching Strategies — Performance optimization
- Microservices — Architecture style using these APIs
Keep reading
Service Discovery
A guide to microservice discovery in dynamic cloud environments, covering client-side vs server-side patterns, health checks, DNS-based discovery, and illustrative integrations using Consul, etcd, Eureka, and Kubernetes services.
BitTorrent: Pieces, Peer Discovery and Swarm Limits
Understand verified piece exchange, v1 versus v2 metadata, peer discovery, and the capacity limits of cooperative distribution.
Content Delivery Networks: Cache Keys, Freshness and Origin Load
Choose safe cache policies, calculate origin offload, and test personalized responses, invalidation and cold-cache recovery.
DNS Architecture
The phonebook of the internet. How Domain Name System works, the DNS hierarchy, and recursive vs iterative resolution strategies.
HTTP Evolution (H1 to H3)
From text-based HTTP/1.1 to binary HTTP/2 and UDP-based HTTP/3 (QUIC). Why we needed upgrades and how they solve Head-of-Line Blocking.
Load Balancing
Layer 4 vs Layer 7 Load Balancing. Algorithms (Round Robin, Least Connections, Consistent Hashing). Health checks and real-world implementation with Nginx.