API Design Patterns: REST vs GraphQL vs gRPC Compared
REST for public APIs, GraphQL for complex frontends, gRPC for service-to-service. Learn when to use each API design pattern, with comparisons of error handling, versioning, authentication, and managed gateway pricing.
Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

Three Protocols, Three Trade-Offs
Every backend team faces this question early: REST, GraphQL, or gRPC? The answer depends on who's consuming your API, what data shapes they need, and whether your communication is client-to-server or service-to-service. I've built production systems with all three, and the right choice has never been "whichever one you know best." Each protocol has a sweet spot, and using the wrong one creates friction that compounds over years.
REST dominates public APIs for good reason -- it's cacheable, widely understood, and works with every HTTP client in existence. GraphQL shines when data-heavy clients need flexible queries without backend changes. gRPC wins for internal service-to-service communication where latency and throughput matter. Here's how to make the decision and implement each one well.
What Is an API Design Pattern?
Definition: An API design pattern is a reusable approach to structuring communication between software components over a network. It defines how clients request data, how servers respond, how errors are reported, and how the contract evolves over time. REST, GraphQL, and gRPC are the three dominant patterns in modern backend development.
REST: The Universal Standard
REST (Representational State Transfer) maps operations to HTTP methods on resources. It's not a protocol -- it's an architectural style. A well-designed REST API is predictable: GET /users/123 returns a user, POST /users creates one, DELETE /users/123 removes one.
When REST Is the Right Choice
- Public APIs -- every developer knows HTTP. No client libraries needed.
- Cacheable resources -- HTTP caching (CDNs, browser cache, reverse proxies) works out of the box with GET requests.
- Simple CRUD operations -- resources map cleanly to database entities.
- Browser-first clients -- fetch() is built into every browser.
REST Best Practices
# Good REST design
GET /api/v1/orders # List orders
GET /api/v1/orders/abc-123 # Get specific order
POST /api/v1/orders # Create order
PATCH /api/v1/orders/abc-123 # Partial update
DELETE /api/v1/orders/abc-123 # Delete order
# Filtering, sorting, pagination
GET /api/v1/orders?status=shipped&sort=-created_at&page=2&limit=20
# Nested resources
GET /api/v1/orders/abc-123/items # Items in an order
GET /api/v1/users/42/orders # Orders for a user
// Consistent error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid order status",
"details": [
{
"field": "status",
"message": "Must be one of: pending, confirmed, shipped, delivered"
}
]
}
}
Pro tip: Use PATCH for partial updates, not PUT. PUT replaces the entire resource, which means clients need to send every field even if they're only changing one. PATCH sends only the fields being changed, reducing payload size and preventing accidental overwrites of fields the client didn't intend to modify.
GraphQL: Flexible Queries for Complex Clients
GraphQL lets clients specify exactly what data they need in a single request. No over-fetching, no under-fetching, no coordinating five REST endpoints to populate one screen. Facebook built it to solve a specific problem: mobile clients with varying data needs and constrained bandwidth.
When GraphQL Is the Right Choice
- Data-heavy frontends -- dashboards, feeds, and complex UIs that need data from multiple entities in a single view.
- Multiple client types -- a mobile app needs fewer fields than a web app. GraphQL lets each client request exactly what it needs.
- Rapid frontend iteration -- frontend teams can change their data requirements without backend changes.
- Graph-shaped data -- social networks, org charts, and interconnected entities where relationships matter.
GraphQL Example
# Schema definition
type Order {
id: ID!
status: OrderStatus!
total: Float!
createdAt: DateTime!
customer: Customer!
items: [OrderItem!]!
}
type Customer {
id: ID!
name: String!
email: String!
orders(first: Int, after: String): OrderConnection!
}
type Query {
order(id: ID!): Order
orders(status: OrderStatus, first: Int, after: String): OrderConnection!
}
type Mutation {
createOrder(input: CreateOrderInput!): Order!
cancelOrder(id: ID!): Order!
}
# Client query -- only fetches what it needs
query OrderDetails {
order(id: "abc-123") {
id
status
total
customer {
name
email
}
items {
productName
quantity
unitPrice
}
}
}
Watch out: GraphQL's biggest operational risk is unbounded query complexity. A malicious or careless client can craft a deeply nested query that joins dozens of tables and kills your database. Always implement query depth limiting, complexity scoring, and query cost analysis. Tools like graphql-depth-limit and graphql-query-complexity are non-negotiable in production.
gRPC: High-Performance Service Communication
gRPC uses Protocol Buffers (Protobuf) for binary serialization and HTTP/2 for transport. It's faster than JSON-over-HTTP, supports streaming, and generates type-safe client/server code from a shared schema. Google built it for internal service-to-service communication, and that remains its sweet spot.
When gRPC Is the Right Choice
- Service-to-service communication -- internal APIs where you control both ends.
- Low-latency requirements -- binary serialization is 5-10x faster than JSON parsing.
- Streaming -- server streaming, client streaming, and bidirectional streaming are first-class features.
- Polyglot environments -- Protobuf generates clients in Go, Java, Python, C++, Rust, and more from a single .proto file.
gRPC Example
// order_service.proto
syntax = "proto3";
package orders;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc StreamOrderUpdates(StreamRequest) returns (stream OrderUpdate);
}
message GetOrderRequest {
string order_id = 1;
}
message Order {
string id = 1;
string customer_id = 2;
OrderStatus status = 3;
double total = 4;
repeated OrderItem items = 5;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
}
message OrderItem {
string product_id = 1;
string product_name = 2;
int32 quantity = 3;
double unit_price = 4;
}
HEAD-TO-HEAD: REST vs GraphQL vs gRPC
| Aspect | REST | GraphQL | gRPC |
|---|---|---|---|
| Data format | JSON (text) | JSON (text) | Protobuf (binary) |
| Transport | HTTP/1.1 or HTTP/2 | HTTP/1.1 or HTTP/2 | HTTP/2 (required) |
| Schema | OpenAPI (optional) | SDL (required) | Protobuf (required) |
| Code generation | Optional (OpenAPI) | Optional but common | Built-in |
| Caching | HTTP caching native | Requires custom layer | No built-in caching |
| Browser support | Native | Native (single POST) | Requires grpc-web proxy |
| Streaming | SSE, WebSockets | Subscriptions (WebSocket) | Native bidirectional |
| Over-fetching | Common problem | Solved by design | Defined by message type |
| Learning curve | Low | Medium | Medium-High |
| Tooling maturity | Excellent | Good | Good (language-dependent) |
| Best for | Public APIs, CRUD | Complex frontends | Internal services |
Error Handling Across All Three
Error handling is where most APIs fall apart. Each protocol has its own conventions:
REST Error Handling
Use HTTP status codes correctly. 400 for client errors, 500 for server errors, and a structured error body. Don't return 200 with an error message in the body -- that breaks every HTTP client's error handling.
// Express error handler
app.use((err: AppError, req: Request, res: Response, next: NextFunction) => {
const status = err.statusCode || 500;
res.status(status).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
},
});
});
GraphQL Error Handling
GraphQL always returns HTTP 200. Errors go in an errors array alongside partial data. This is by design -- a single query might partially succeed.
{
"data": {
"order": {
"id": "abc-123",
"status": "CONFIRMED",
"customer": null
}
},
"errors": [
{
"message": "Customer service unavailable",
"path": ["order", "customer"],
"extensions": {
"code": "SERVICE_UNAVAILABLE"
}
}
]
}
gRPC Error Handling
gRPC uses status codes (OK, INVALID_ARGUMENT, NOT_FOUND, INTERNAL, etc.) with optional error details. The status codes are well-defined and map cleanly to business errors.
// Go gRPC error with details
import "google.golang.org/grpc/status"
import "google.golang.org/grpc/codes"
func (s *server) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
order, err := s.repo.FindByID(ctx, req.OrderId)
if err != nil {
return nil, status.Errorf(codes.NotFound, "order %s not found", req.OrderId)
}
return order, nil
}
API Versioning Strategies
APIs evolve. How you handle breaking changes depends on your protocol:
| Strategy | REST | GraphQL | gRPC |
|---|---|---|---|
| URL versioning | /api/v1/orders | Not applicable | Package versioning |
| Header versioning | Accept: application/vnd.api.v2+json | Not applicable | Not applicable |
| Schema evolution | Add fields, deprecate old ones | @deprecated directive | Never reuse field numbers |
| Breaking changes | New version endpoint | Avoid -- use deprecation | New service version in proto |
Pro tip: GraphQL's type system makes versioning largely unnecessary. Instead of creating a v2 endpoint, add new fields and mark old ones with
@deprecated(reason: "Use newField instead"). Clients migrate at their own pace, and you can track deprecated field usage to know when it's safe to remove them.
Authentication and Authorization Patterns
Auth patterns are similar across all three protocols but differ in implementation details:
- API Keys -- simplest approach. Pass a key in a header (
X-API-Key) or query parameter. Good for server-to-server communication. Don't use for browser clients -- the key will be visible. - OAuth 2.0 / JWT -- the standard for user-facing APIs. Issue a JWT after authentication, validate it on each request. Works identically for REST and GraphQL. For gRPC, pass the token in metadata.
- mTLS -- mutual TLS for service-to-service auth. Both client and server present certificates. Common in service mesh architectures (Istio handles this automatically). Strongest option for internal APIs.
- Per-field authorization (GraphQL) -- GraphQL's flexibility requires field-level auth. A user might be able to read
order.statusbut notorder.internalNotes. Implement this in resolvers or with schema directives.
Managed API Gateway Pricing
If you're exposing APIs at scale, an API gateway handles rate limiting, auth, and routing. Here's what the managed options cost:
| Service | Pricing Model | Estimated Monthly Cost (10M requests) | Supports |
|---|---|---|---|
| AWS API Gateway (REST) | $3.50 per million requests | $35 | REST, WebSocket |
| AWS API Gateway (HTTP) | $1.00 per million requests | $10 | HTTP proxy, cheaper |
| AWS AppSync | $4.00 per million queries | $40 | GraphQL managed |
| Kong Gateway (Cloud) | Plan-based | $150 - $500 | REST, gRPC, GraphQL |
| Apigee (Google) | Plan-based | $500 - $2,500 | REST, enterprise features |
| Azure API Management | Tier-based | $150 - $2,500 | REST, WebSocket, GraphQL |
Frequently Asked Questions
Should I use REST or GraphQL for my new project?
If you're building a public API or a simple CRUD backend, use REST. It's simpler, cacheable, and universally understood. If you're building a data-heavy frontend with complex data requirements or multiple client types, GraphQL eliminates the over-fetching problem and gives frontend teams independence.
Is gRPC faster than REST?
Yes, significantly. Protobuf binary serialization is 5-10x faster than JSON parsing, and HTTP/2 multiplexing reduces latency for concurrent requests. In benchmarks, gRPC typically handles 2-5x more requests per second than equivalent REST endpoints. The difference matters most for internal service-to-service calls at high volume.
Can I use GraphQL and REST in the same application?
Absolutely, and many teams do. A common pattern is REST for simple public endpoints and webhooks, GraphQL for the main application frontend, and gRPC for internal service communication. There's no rule that says you must pick one protocol for everything.
How do I prevent expensive GraphQL queries from crashing my server?
Implement query complexity analysis that assigns a cost to each field and rejects queries exceeding a threshold. Set a maximum query depth (typically 7-10 levels). Use persisted queries in production so clients can only execute pre-approved queries. Dataloader batching prevents the N+1 query problem in resolvers.
What is the N+1 problem in GraphQL?
When resolving a list of orders with their customers, a naive implementation executes one query for the orders list and then one query per order to fetch each customer -- N+1 queries total. The Dataloader pattern batches these into two queries: one for orders, one for all referenced customers. Always use Dataloader or equivalent batching.
How do I version a gRPC API?
Protobuf has built-in backward compatibility rules. Never reuse or reassign field numbers. Add new fields with new numbers. Deprecated fields can be marked as reserved. For breaking changes, create a new service version in a new proto package. Clients can migrate at their own pace since old and new services can run simultaneously.
Do I need an API gateway?
If you're exposing APIs to external consumers, yes. An API gateway handles rate limiting, authentication, request transformation, and monitoring in one place. For internal-only APIs in a service mesh, the mesh handles most of these concerns and a separate gateway is often unnecessary.
Choose Based on Your Consumer
The protocol decision comes down to who's consuming your API. Public consumers get REST because it's universal. Complex frontends get GraphQL because it's flexible. Internal services get gRPC because it's fast. Most production systems use at least two of these protocols, and that's perfectly fine. Pick the right tool for each communication boundary rather than forcing one protocol everywhere.
Written by
Abhishek Patel
Infrastructure engineer with 10+ years building production systems on AWS, GCP, and bare metal. Writes practical guides on cloud architecture, containers, networking, and Linux for developers who want to understand how things actually work under the hood.
Related Articles
Event-Driven Architecture: When It Makes Sense and When It Doesn't
Event-driven architecture decouples services through message brokers like Kafka, RabbitMQ, and SNS/SQS. Learn when EDA is the right choice, how to implement it, and the patterns that make it work in production.
13 min read
ArchitectureCQRS and Event Sourcing: A Practical Introduction
CQRS separates read and write models for independent optimization. Event Sourcing stores every state change as an immutable event. Learn when each pattern solves real problems, with TypeScript implementations, projection examples, and event store comparisons.
12 min read
ArchitectureRate Limiting: Token Bucket, Leaky Bucket, Sliding Window, and Fixed Counter
Compare the four main rate limiting algorithms -- Token Bucket, Leaky Bucket, Sliding Window Log, and Fixed Window Counter. Includes Redis Lua implementations, nginx configuration, distributed rate limiting patterns, and managed service pricing.
12 min read
Enjoyed this article?
Get more like this in your inbox. No spam, unsubscribe anytime.