Choosing the right API style is a common system design discussion. This guide covers REST, GraphQL, and gRPC — when to use each, trade-offs, and interview-ready talking points.
- Stateless — each request contains all info needed
- Resource-based — URLs represent nouns, HTTP verbs represent actions
- Uniform interface — standard HTTP methods
- Cacheable — responses can be cached
| Method | Action | Idempotent | Safe |
|---|---|---|---|
| GET | Read | ✅ | ✅ |
| POST | Create | ❌ | ❌ |
| PUT | Replace | ✅ | ❌ |
| PATCH | Partial update | ❌ | ❌ |
| DELETE | Delete | ✅ | ❌ |
# Resource naming — use nouns, not verbs
✅ GET /users/123
❌ GET /getUser?id=123
# Nested resources for relationships
GET /users/123/orders # All orders for user 123
GET /users/123/orders/456 # Specific order
# Filtering, sorting, pagination via query params
GET /products?category=electronics&sort=price&order=asc&page=2&limit=20
# Versioning
GET /v1/users/123
GET /v2/users/123
# Status codes
200 OK — Success
201 Created — Resource created (POST)
204 No Content — Success, no body (DELETE)
400 Bad Request — Invalid input
401 Unauthorized — Not authenticated
403 Forbidden — Authenticated but not authorized
404 Not Found — Resource doesn't exist
409 Conflict — Duplicate resource
422 Unprocessable — Validation error
429 Too Many Requests — Rate limited
500 Internal Server Error
// Standard response envelope
{
"success": true,
"data": {
"id": "user_123",
"name": "Rahul Sharma",
"email": "rahul@example.com"
},
"meta": {
"page": 1,
"limit": 20,
"total": 150
}
}
// Error response
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required",
"details": [
{ "field": "email", "message": "Email cannot be empty" }
]
}
}| ✅ Pros | ❌ Cons |
|---|---|
| Simple, widely understood | Over-fetching (getting more data than needed) |
| Great tooling (Postman, Swagger) | Under-fetching (multiple requests for related data) |
| HTTP caching built-in | No real-time support natively |
| Stateless = easy to scale | Versioning can get messy |
| Works everywhere | N+1 query problem |
- Single endpoint —
POST /graphql - Client specifies shape — ask for exactly what you need
- Strongly typed schema — self-documenting
- Queries (read), Mutations (write), Subscriptions (real-time)
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
createdAt: String!
}
type Order {
id: ID!
total: Float!
status: String!
items: [OrderItem!]!
}
type OrderItem {
product: Product!
quantity: Int!
price: Float!
}
type Query {
user(id: ID!): User
users(page: Int, limit: Int): [User!]!
order(id: ID!): Order
}
type Mutation {
createUser(name: String!, email: String!): User!
updateUser(id: ID!, name: String): User!
deleteUser(id: ID!): Boolean!
placeOrder(userId: ID!, items: [OrderItemInput!]!): Order!
}
type Subscription {
orderStatusChanged(orderId: ID!): Order!
}# Fetch only what you need (no over-fetching)
query GetUserWithOrders {
user(id: "123") {
name
email
orders {
id
total
status
}
}
}
# Mutation
mutation CreateUser {
createUser(name: "Priya", email: "priya@example.com") {
id
name
}
}
# Real-time subscription
subscription TrackOrder {
orderStatusChanged(orderId: "ORD-456") {
id
status
}
}const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
return dataSources.userAPI.getUser(id)
},
users: async (_, { page = 1, limit = 20 }, { dataSources }) => {
return dataSources.userAPI.getUsers({ page, limit })
}
},
Mutation: {
createUser: async (_, { name, email }, { dataSources }) => {
return dataSources.userAPI.createUser({ name, email })
}
},
// Field-level resolver — runs when 'orders' field is requested
User: {
orders: async (user, _, { dataSources }) => {
return dataSources.orderAPI.getOrdersByUser(user.id)
}
}
}// ❌ Problem: 1 query for users + N queries for each user's orders
// If 100 users → 101 DB queries!
// ✅ Solution: DataLoader batches requests
const DataLoader = require('dataloader')
const orderLoader = new DataLoader(async (userIds) => {
// One query for ALL users' orders
const orders = await db.query(
'SELECT * FROM orders WHERE user_id IN (?)',
[userIds]
)
// Group by userId
const ordersByUser = userIds.map(id =>
orders.filter(o => o.user_id === id)
)
return ordersByUser
})
// Now User.orders resolver uses the loader
User: {
orders: (user, _, { loaders }) => loaders.order.load(user.id)
}
// 100 users → 2 DB queries (1 for users, 1 batched for all orders)| ✅ Pros | ❌ Cons |
|---|---|
| No over/under-fetching | Complex to set up |
| Single endpoint | N+1 problem (need DataLoader) |
| Self-documenting schema | Harder to cache (POST requests) |
| Great for complex, nested data | Overkill for simple CRUD |
| Real-time via subscriptions | Learning curve |
- Protocol Buffers — binary serialization (smaller, faster than JSON)
- HTTP/2 — multiplexing, bidirectional streaming
- Strongly typed —
.protoschema files - 4 communication patterns — unary, server streaming, client streaming, bidirectional
syntax = "proto3";
package user;
service UserService {
// Unary RPC
rpc GetUser (GetUserRequest) returns (User);
// Server streaming — server sends multiple responses
rpc ListUsers (ListUsersRequest) returns (stream User);
// Client streaming — client sends multiple requests
rpc CreateUsers (stream CreateUserRequest) returns (CreateUsersResponse);
// Bidirectional streaming
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message User {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
message GetUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page = 1;
int32 limit = 2;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message CreateUsersResponse {
repeated User users = 1;
int32 created_count = 2;
}const grpc = require('@grpc/grpc-js')
const protoLoader = require('@grpc/proto-loader')
const packageDef = protoLoader.loadSync('user.proto')
const proto = grpc.loadPackageDefinition(packageDef).user
const server = new grpc.Server()
server.addService(proto.UserService.service, {
getUser: async (call, callback) => {
const { id } = call.request
const user = await db.findUser(id)
if (!user) {
return callback({ code: grpc.status.NOT_FOUND, message: 'User not found' })
}
callback(null, user)
},
listUsers: async (call) => {
const users = await db.getUsers(call.request)
users.forEach(user => call.write(user))
call.end()
}
})
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
server.start()
})| ✅ Pros | ❌ Cons |
|---|---|
| Very fast (binary + HTTP/2) | Not human-readable |
| Streaming support | Limited browser support |
| Strong typing | Steeper learning curve |
| Code generation | Harder to debug |
| Great for microservices | Overkill for public APIs |
Decision Tree:
Is it a public API?
├── Yes → REST (universal support, easy to consume)
└── No (internal microservices)
├── Need real-time / streaming? → gRPC or GraphQL subscriptions
├── Complex nested data, mobile client? → GraphQL
└── High performance, service-to-service? → gRPC
Is the client mobile?
├── Yes → GraphQL (reduce over-fetching, save bandwidth)
└── No → REST or gRPC
Need real-time?
├── Yes → WebSockets, gRPC streaming, or GraphQL subscriptions
└── No → REST or GraphQL queries
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Protocol | HTTP/1.1 | HTTP/1.1 | HTTP/2 |
| Format | JSON | JSON | Protobuf (binary) |
| Schema | OpenAPI (optional) | Required | Required (.proto) |
| Caching | Easy (GET) | Hard | Hard |
| Real-time | Polling/SSE | Subscriptions | Streaming |
| Browser support | ✅ | ✅ | |
| Performance | Good | Good | Excellent |
| Best for | Public APIs, CRUD | Complex data, mobile | Microservices, streaming |
// JWT Authentication middleware
const jwt = require('jsonwebtoken')
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({ error: 'No token provided' })
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = decoded
next()
} catch (err) {
return res.status(401).json({ error: 'Invalid token' })
}
}
// Role-based authorization
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
next()
}
}
// Usage
app.get('/admin/users', authenticate, authorize('admin'), getUsers)
app.get('/profile', authenticate, getProfile)const rateLimit = require('express-rate-limit')
// Global rate limit
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, please try again later' }
})
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 min
message: { error: 'Too many login attempts' }
})
app.use('/api/', globalLimiter)
app.use('/api/auth/login', authLimiter)const { body, validationResult } = require('express-validator')
const createUserValidation = [
body('email').isEmail().normalizeEmail(),
body('name').trim().isLength({ min: 2, max: 50 }),
body('password').isLength({ min: 8 }).matches(/^(?=.*[A-Z])(?=.*[0-9])/),
]
app.post('/users', createUserValidation, (req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
// Proceed with creation
})// Strategy 1: URL versioning (most common)
app.use('/v1', v1Router)
app.use('/v2', v2Router)
// GET /v1/users vs GET /v2/users
// Strategy 2: Header versioning
app.use((req, res, next) => {
const version = req.headers['api-version'] || 'v1'
req.apiVersion = version
next()
})
// Strategy 3: Query param
// GET /users?version=2
// Strategy 4: Content negotiation
// Accept: application/vnd.myapi.v2+jsonRecommendation: URL versioning for public APIs (most visible, easiest to test)
"Why REST over GraphQL?"
REST is simpler, has better caching support, and is universally understood. For a CRUD API with predictable data shapes, REST is the right choice. GraphQL shines when clients have diverse data needs or when you want to avoid multiple round trips.
"Why gRPC for microservices?"
gRPC uses Protocol Buffers (binary, ~5x smaller than JSON) over HTTP/2 (multiplexed connections). For internal service-to-service calls doing thousands of RPC calls per second, this performance difference is significant. The strongly-typed schema also acts as a contract between services.
"How do you handle API versioning?"
I prefer URL versioning (
/v1/,/v2/) for public APIs because it's explicit and easy to test. For internal APIs, I use header versioning to keep URLs clean. The key is maintaining backward compatibility — never remove fields, only add them.