RS
Ravi Shukla
HomeBlogToolsAbout
Resume
RS
Ravi Shukla

Senior Java + AI engineer. Kafka, RAG, distributed systems.

Content

  • Blog
  • System Design
  • AI & ML
  • DevOps

Explore

  • About Ravi
  • Open Stats
  • Thank You

© 2026 Ravi Kant Shukla. All rights reserved.

Deployed on Vercel · Mumbai region

Back to Writing
hld

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.

June 2, 202622 min read
api-designrestgraphqlgrpcmicroservicessystem-design

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

Graphql
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

Graphql
{
  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.

Protobuf
// 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
}
Java
// 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.

Protobuf
service OrderService {
  rpc WatchOrder (WatchOrderRequest) returns (stream OrderUpdate);
}
Java
// 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
Json
{
  "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.

Graphql
# 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
R

Ravi Kant Shukla

Senior Java + AI engineer. 9+ years in system design, Kafka, microservices, and LLM/RAG pipelines.

About Ravi →More Posts →

Enjoyed this post?

Get more system design and AWS insights delivered weekly. No spam.

Comments (0)

Loading comments...

Leave a comment

Your email will not be displayed publicly.

Related Posts

hld

Database Design for Scalability: SQL, NoSQL, and Polyglot Persistence

Database decisions outlive most code. This post covers relational vs. document vs. key-value vs. time-series vs. graph databases, polyglot persistence, sharding vs. replication, schema evolution, and the decision framework senior engineers use to pick the right store for the job.

database-designsqlnosql
Jun 23, 202624 min read
hld

Consistency & Distributed Transactions: Understanding CAP, ACID, and When to Compromise

Master distributed systems consistency guarantees. Explore ACID vs BASE, CAP theorem nuances, 2PC limitations, consensus algorithms, and when to accept inconsistency. Covers Raft, Paxos, CRDTs, and real-world trade-offs senior engineers face.

distributed-systemsconsistencyconsensus
Jun 12, 202625 min read
hld

Service Boundaries & Event-Driven Architecture: Decoupling at Scale

Master the art of defining service boundaries using domain-driven design principles. Explore event-driven architecture, async patterns, saga implementations, and how to decouple services while maintaining consistency and system observability.

microservicesevent-driven-architecturedomain-driven-design
May 22, 202622 min read