API Design at Scale: REST, GraphQL, and gRPC Trade-offs
A practical decision framework for choosing between REST, GraphQL, and gRPC. Covers HTTP semantics, N+1 problems, schema enforcement, streaming, API versioning, rate limiting, and gateway patterns — with real trade-offs senior engineers face.
API Design at Scale: REST, GraphQL, and gRPC Trade-offs
TLDR: REST is simple and cacheable but over-fetches. GraphQL gives clients control but kills caching. gRPC is fast and type-safe but browser-unfriendly. The decision isn't ideological — it's about who your client is, how often the schema changes, and whether you need streaming. Most systems need all three.
This post is part of the HLD series. We covered Service Boundaries & Event-Driven Architecture in the previous post — your services are now decoupled. Now let's design the APIs they expose.
API Decision Framework
Part 1: REST — The Default and Its Limits
What Makes REST, REST
REST (Representational State Transfer) is an architectural style, not a protocol. The constraints that matter in practice:
- Uniform interface: Resources identified by URLs (
/users/123,/orders/456) - Stateless: Each request carries all context (no server-side session)
- Cacheable: Responses declare cacheability via HTTP headers
- Client-server: Client and server evolve independently
REST resource design:
GET /users/123 → Fetch user
POST /users → Create user
PUT /users/123 → Replace user
PATCH /users/123 → Partial update
DELETE /users/123 → Delete user
GET /users/123/orders → Fetch orders for user
POST /users/123/orders → Create order for user
GET /orders/456 → Fetch specific order
REST Advantages
1. HTTP Semantics (Caching)
GET /products/123
Response headers:
Cache-Control: public, max-age=3600
ETag: "abc123"
Client caches for 1 hour.
Next request:
If-None-Match: "abc123"
→ 304 Not Modified (no body, < 1ms)
REST is the only option where the network layer
(CDN, browser, proxy) can cache responses automatically.
2. Simplicity
Any HTTP client works. curl, browser, Postman, fetch(). No special library required. Every language has an HTTP client.
3. Debuggability
GET /orders/456 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJ...
HTTP/1.1 200 OK
Content-Type: application/json
{"id": "456", "status": "shipped", "total": 49.99}
Human-readable. Open in browser. Log in plain text.
REST Limitations
1. Over-fetching
GET /users/123
Response:
{
"id": "123",
"name": "Alice",
"email": "alice@example.com", ← need this
"bio": "...", ← don't need this (100 chars)
"avatar_url": "...", ← don't need this (URL)
"created_at": "...", ← don't need this
"subscription": {...}, ← don't need this (nested object)
"preferences": {...} ← don't need this (another object)
}
Mobile client wanted: just name + email.
Received: 2KB of data instead of 50 bytes.
Multiplied by 10K req/s = wasted bandwidth.
2. The N+1 Problem
Render a user's order list with product names:
GET /users/123/orders → [{order_id: 1}, {order_id: 2}, {order_id: 3}]
GET /orders/1/products → [{product_id: 10}, {product_id: 11}]
GET /orders/2/products → [{product_id: 12}]
GET /orders/3/products → [{product_id: 10}, {product_id: 13}]
GET /products/10 → {name: "Widget A"}
GET /products/11 → {name: "Widget B"}
...
1 + 3 + 4 = 8 round trips to render one page.
Each adds ~50ms network latency.
Total overhead: 400ms before any processing.
Fix: Use compound documents (?include=products) or BFF pattern. But these are workarounds, not the protocol's strength.
3. Versioning Pain
URL versioning (most common):
/v1/users/123
/v2/users/123 ← breaking change
Header versioning:
Accept: application/vnd.api.v2+json
Problems:
├─ Multiple versions to maintain simultaneously
├─ Clients upgrade at different rates
└─ Documentation diverges per version
Part 2: GraphQL — Client-Driven Queries
What GraphQL Solves
GraphQL lets clients request exactly the fields they need — no more, no less.
Schema (server defines):
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
status: String!
total: Float!
items: [OrderItem!]!
}
Query (client specifies exactly what it wants):
query {
user(id: "123") {
name
email
orders {
id
status
total
}
}
}
Response (only requested fields):
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com",
"orders": [
{"id": "1", "status": "shipped", "total": 49.99},
{"id": "2", "status": "pending", "total": 29.99}
]
}
}
}
One request. Exactly what the client asked for. Server resolves relationships internally.
GraphQL Advantages
1. Eliminates Over-fetching and Under-fetching
Mobile client: Desktop client:
name, email only name, email, bio, avatar, orders
→ small payload → full payload
Same API endpoint, different queries.
No versioning required for field additions.
2. Schema as Contract
type Mutation {
createOrder(input: CreateOrderInput!): OrderResult!
}
input CreateOrderInput {
userId: ID!
items: [OrderItemInput!]!
couponCode: String # Optional
}
type OrderResult {
order: Order
errors: [UserError!]!
}
Schema is strongly typed. IDEs autocomplete. Breaking changes are detectable at build time.
3. Introspection
POST /graphql
{
"__schema": {
"types": [...]
}
}
Clients can query the schema itself. Tools like GraphiQL, Apollo Studio, and Postman's GraphQL mode generate UI from schema automatically.
GraphQL Limitations
1. Caching Is Hard
REST:
GET /products/123 → cacheable (URL is cache key)
CDN caches response for 1 hour ✓
GraphQL:
POST /graphql {query: "{ product(id: 123) { name price } }"}
POST /graphql {query: "{ product(id: 123) { name } }"}
Same product, different queries, different cache keys?
Most HTTP caches can't differentiate (all are POST /graphql).
Workaround: Persisted queries (hash → query ID, use GET)
But: requires extra tooling + client coordination.
2. N+1 Under the Hood
Query:
{ orders { user { name } } }
Naive resolver:
orders.forEach(order => {
db.query("SELECT name FROM users WHERE id = ?", order.userId)
})
100 orders = 101 queries (1 for orders + 100 for users).
Same N+1 problem, just hidden from the client.
Fix: DataLoader (batches and deduplicates DB calls per request).
Must be implemented deliberately by the server.
3. Query Complexity Attacks
{
users {
orders {
user {
orders {
user {
orders {
# ...infinite nesting
}
}
}
}
}
}
}
A malicious (or careless) client can craft a query that explodes into millions of DB calls. GraphQL doesn't protect against this by default.
Fix: Depth limits, complexity scoring, query cost analysis. Must be explicitly configured.
4. File Uploads Are Awkward
GraphQL is JSON-native. Binary uploads require multipart spec workarounds. REST handles files naturally (multipart/form-data).
Part 3: gRPC — Performance and Type Safety
What gRPC Is
gRPC is a Remote Procedure Call framework built on HTTP/2 and Protocol Buffers (Protobuf). You define a service contract in .proto files; gRPC generates client and server code in your language.
// orders.proto
syntax = "proto3";
service OrderService {
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
rpc GetOrder (GetOrderRequest) returns (Order);
rpc StreamOrders (StreamOrdersRequest) returns (stream Order); // server streaming
rpc BatchCreateOrders (stream CreateOrderRequest) returns (BatchResult); // client streaming
}
message CreateOrderRequest {
string user_id = 1;
repeated OrderItem items = 2;
string coupon_code = 3;
}
message Order {
string id = 1;
string status = 2;
double total = 3;
int64 created_at = 4; // unix timestamp
}
// Generated Java client (Spring Boot):
OrderServiceGrpc.OrderServiceBlockingStub stub =
OrderServiceGrpc.newBlockingStub(channel);
Order order = stub.getOrder(
GetOrderRequest.newBuilder().setOrderId("456").build()
);
gRPC Advantages
1. Performance
REST (JSON over HTTP/1.1):
Header: "Content-Type: application/json\r\n" (text, verbose)
Body: {"order_id": "456", "status": "shipped", "total": 49.99}
Size: ~200 bytes
Connection: one request per TCP connection
gRPC (Protobuf over HTTP/2):
Header: binary-encoded (Hpack compressed)
Body: binary-encoded (field 1=456, field 2=shipped, field 3=49.99)
Size: ~20 bytes (10x smaller)
Connection: multiplexed over single TCP connection (no head-of-line blocking)
Result: ~5-7x faster than REST for equivalent payloads at high throughput.
2. Bidirectional Streaming
Four communication patterns:
Unary RPC (like REST):
Client: one request → Server: one response
Server streaming:
Client: one request → Server: stream of responses
Example: GET /orders/live-updates → stream of order status changes
Client streaming:
Client: stream of requests → Server: one response
Example: upload log entries in bulk → confirmation
Bidirectional streaming:
Client: stream of requests ↔ Server: stream of responses
Example: real-time chat, multiplayer game state
3. Strong Schema Enforcement
Breaking change detection:
.proto v1: .proto v2:
message Order { message Order {
string id = 1; string id = 1;
string status = 2; // status removed ← BREAKING
string state = 3; ← new field
} }
Protobuf field numbers (1, 2, 3) are the contract.
Remove field 2 → clients expecting field 2 get empty string.
Must keep old field numbers for backward compatibility.
4. Code Generation
protoc --java_out=. --grpc-java_out=. orders.proto
Generated:
├─ OrderServiceGrpc.java (server stub, client stub)
├─ CreateOrderRequest.java (builder, serialization)
├─ Order.java (builder, serialization)
└─ ... all message types
No manual serialization/deserialization.
No documentation drift (schema IS the doc).
gRPC Limitations
1. Browser Support Is Limited
HTTP/2 with gRPC requires:
├─ Full control over HTTP/2 framing
└─ Browsers don't expose this via Fetch API / XHR
Solution: gRPC-Web (proxy translates gRPC → HTTP/1.1 for browsers)
Client → gRPC-Web Proxy (Envoy) → gRPC Server
Extra hop. Extra infrastructure. Not native browser support.
2. Not Human-Readable
REST debug: curl https://api.example.com/orders/123
→ JSON in terminal ✓
gRPC debug: grpcurl -d '{"order_id": "123"}' api.example.com OrderService/GetOrder
→ Requires grpcurl, proto file, TLS config ✗
Wireshark shows binary. Logging binary payloads is painful.
Requires dedicated tooling (BloomRPC, Postman gRPC, grpcurl).
3. Schema Evolution Is Strict
Protobuf backward compatibility rules:
✓ Add new fields (old clients ignore them)
✓ Add new message types
✗ Remove existing fields (never reuse field numbers)
✗ Change field types (1 was int32, now string — binary breakage)
✗ Change field numbers
More rigid than REST + JSON (extra fields in JSON are ignored naturally).
Part 4: Decision Framework — Which to Choose
By Client Type
Client Recommended Why
────────────────────────────────────────────────────────────
Browser (public API) REST Universal support, caching
Mobile (public API) REST or GraphQL GraphQL saves bandwidth
Server-to-server gRPC Performance, type safety, streaming
Internal microservices gRPC Low latency, generated stubs
Data-intensive mobile GraphQL Exact fields, avoid over-fetching
Public developer API REST Simplicity, curl-friendly, docs
Real-time streaming gRPC Bidirectional streams
BFF (mobile backend) GraphQL Client-specific query shapes
By Feature
Need Use Why
──────────────────────────────────────────────────────────
HTTP caching (CDN) REST CDN understands GET + Cache-Control
Exact field selection GraphQL Client specifies the query
Sub-10ms service latency gRPC Binary + HTTP/2 + streaming
Schema-first contract gRPC .proto = code + docs + validation
Flexible queries (dashboards) GraphQL Client builds the query shape
Large binary payloads REST multipart/form-data support
Backward compat via JSON REST Unknown clients don't break
Bidirectional streaming gRPC Only protocol with this natively
Developer portal / public SDK REST Human-readable, curl-friendly
Decision Tree
Is your client a browser or a public developer?
YES → REST (maybe GraphQL for mobile)
NO (internal services):
Do you need streaming?
YES → gRPC
NO:
Do different clients need different field subsets?
YES → GraphQL
NO:
Is performance the primary concern?
YES → gRPC
NO → REST (simpler, more debuggable)
Part 5: API Gateway Pattern
An API Gateway is the single entry point for all client requests. It handles cross-cutting concerns so individual services don't have to.
External clients:
Browser, Mobile, Third-party
↓
┌──────────────────────────────────┐
│ API Gateway │
│ │
│ ├─ Authentication (JWT verify) │
│ ├─ Rate limiting (per client) │
│ ├─ Request routing │
│ ├─ Protocol translation │
│ │ (REST → gRPC internally) │
│ ├─ Response aggregation (BFF) │
│ └─ Observability (trace ID) │
└──────────────────────────────────┘
↓
Internal services (gRPC):
OrderService | UserService | InventoryService
Rate Limiting at the Gateway
Strategies:
Token Bucket (Stripe's approach):
├─ Each client gets a bucket of N tokens
├─ Each request consumes 1 token
├─ Tokens replenish at rate R per second
└─ When bucket empty → 429 Too Many Requests
Sliding Window (more accurate):
├─ Track request count per client in last N seconds
├─ Uses Redis sorted set (score = timestamp)
└─ More accurate than fixed window (no boundary burst)
Per-endpoint limits (Stripe):
GET /charges → 100 req/s per key
POST /charges → 25 req/s per key
GET /charges/:id → 200 req/s per key
API Versioning Strategies
URL versioning (simplest, most common):
/v1/orders
/v2/orders
Pros: Explicit, easy to route
Cons: URL pollution, maintain multiple versions in parallel
Header versioning (cleaner URLs):
GET /orders
Headers: API-Version: 2
Pros: Clean URLs
Cons: Not visible in browser, harder to test with curl
Content negotiation (REST-pure):
Accept: application/vnd.example.v2+json
Pros: Theoretically correct
Cons: Complex, rarely used outside media APIs
Backward-compatible evolution (best strategy):
├─ Never remove or rename fields (add new ones)
├─ Mark old fields deprecated (but keep them)
├─ Use feature flags to gate new behaviour
└─ Version only on true breaking changes
Goal: Avoid versioning altogether.
If you must version, use URL versioning.
Part 6: Real-Time APIs
WebSockets
Persistent bidirectional connection. Client and server can send messages at any time.
Use case: Live order status, chat, collaborative editing
Client connects:
GET /ws/orders/456
Upgrade: websocket
→ 101 Switching Protocols
Both sides can now send frames:
Server → Client: {"status": "picked", "updated_at": "..."}
Server → Client: {"status": "packed", "updated_at": "..."}
Server → Client: {"status": "shipped", "tracking": "UPS-123"}
Connection stays open until either side closes it.
Trade-offs:
- Stateful (no horizontal scaling without sticky sessions or pub/sub)
- Complex reconnection logic required on client
- Not cacheable, not REST-compatible
Server-Sent Events (SSE)
One-way: server pushes events to client over long-lived HTTP connection.
Use case: Live dashboards, notification feeds, progress bars
GET /orders/456/events
Accept: text/event-stream
Server streams:
data: {"status": "picked"}
data: {"status": "packed"}
data: {"status": "shipped", "tracking": "UPS-123"}
Simpler than WebSockets. Works over HTTP/1.1. Browser reconnects automatically. No library needed.
Trade-offs:
- One-directional (server → client only)
- Limited to text/JSON (no binary)
- Max 6 connections per domain in HTTP/1.1 (not an issue with HTTP/2)
gRPC Streaming
Best for server-to-server real-time communication.
service OrderService {
rpc WatchOrder (WatchOrderRequest) returns (stream OrderUpdate);
}
// Spring Boot gRPC streaming consumer
stub.watchOrder(request, new StreamObserver<OrderUpdate>() {
@Override
public void onNext(OrderUpdate update) {
processUpdate(update);
}
@Override
public void onError(Throwable t) {
handleError(t);
}
@Override
public void onCompleted() {
log.info("Stream completed");
}
});
Advantages over WebSockets for internal use: typed, code-generated, bidirectional, multiplexed over HTTP/2.
Part 7: Real-World API Architectures
Stripe (REST Mastery)
Stripe's API is the gold standard for REST design:
- Versioned by date (
Stripe-Version: 2024-04-10) — no URL versioning - Idempotency keys on all write endpoints
- Consistent error format across all endpoints
- Webhook callbacks for async events
- Expansions (
?expand=customer) to reduce N+1
{
"error": {
"type": "card_error",
"code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": "amount",
"doc_url": "https://stripe.com/docs/error-codes/insufficient-funds"
}
}
Lesson: Consistency and documentation matter more than protocol choice.
GitHub (GraphQL + REST)
GitHub offers both: REST v3 for simple operations, GraphQL v4 for complex queries.
# Get PR reviews + reviewer status in one query
query {
repository(owner: "github", name: "docs") {
pullRequest(number: 123) {
title
reviews(last: 5) {
nodes {
author { login }
state
body
}
}
}
}
}
Without GraphQL, this requires 3 REST calls. With GraphQL: one.
Lesson: GraphQL shines when clients need to compose data across multiple resources.
Netflix (gRPC Internally, REST Externally)
Netflix uses gRPC between internal microservices for latency, and REST at the edge for device compatibility. Their API gateway translates protocols.
Netflix App → REST → API Gateway → gRPC → Microservices
Lesson: Use gRPC where you control both sides. Use REST/GraphQL at the boundary with external clients.
Conclusion
REST is still the right default for public APIs. It's cacheable, debuggable, and universally understood. Use it for external-facing endpoints and developer APIs.
GraphQL wins when mobile clients or front-ends need flexible, bandwidth-efficient queries. Use it when different clients need different shapes of the same data.
gRPC wins for internal service-to-service communication, especially where you need streaming or sub-10ms latency. Use it behind your API gateway, not in front of it.
The real world uses all three:
- Public API: REST
- Mobile BFF: GraphQL
- Internal services: gRPC
Up next: Database Design for Scalability — your APIs are designed. Now what do they read from and write to? SQL, NoSQL, polyglot persistence, sharding, and schema evolution — the database decisions that will haunt you for years.
Further Reading
- REST API Design Rulebook by Mark Masse — REST constraints in practice
- GraphQL Specification (graphql.org) — the canonical reference
- gRPC documentation (grpc.io) — proto3, streaming patterns, deadlines
- Stripe API Docs — the best example of production REST design
- Designing Web APIs by Brenda Jin et al. — protocol comparison with real trade-offs
Ravi Kant Shukla
Senior Java + AI engineer. 9+ years in system design, Kafka, microservices, and LLM/RAG pipelines.
Enjoyed this post?
Get more system design and AWS insights delivered weekly. No spam.