Architecture

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.

A
Abhishek Patel10 min read

Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

API Design Patterns: REST vs GraphQL vs gRPC Compared
API Design Patterns: REST vs GraphQL vs gRPC Compared

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

AspectRESTGraphQLgRPC
Data formatJSON (text)JSON (text)Protobuf (binary)
TransportHTTP/1.1 or HTTP/2HTTP/1.1 or HTTP/2HTTP/2 (required)
SchemaOpenAPI (optional)SDL (required)Protobuf (required)
Code generationOptional (OpenAPI)Optional but commonBuilt-in
CachingHTTP caching nativeRequires custom layerNo built-in caching
Browser supportNativeNative (single POST)Requires grpc-web proxy
StreamingSSE, WebSocketsSubscriptions (WebSocket)Native bidirectional
Over-fetchingCommon problemSolved by designDefined by message type
Learning curveLowMediumMedium-High
Tooling maturityExcellentGoodGood (language-dependent)
Best forPublic APIs, CRUDComplex frontendsInternal 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:

StrategyRESTGraphQLgRPC
URL versioning/api/v1/ordersNot applicablePackage versioning
Header versioningAccept: application/vnd.api.v2+jsonNot applicableNot applicable
Schema evolutionAdd fields, deprecate old ones@deprecated directiveNever reuse field numbers
Breaking changesNew version endpointAvoid -- use deprecationNew 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:

  1. 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.
  2. 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.
  3. 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.
  4. Per-field authorization (GraphQL) -- GraphQL's flexibility requires field-level auth. A user might be able to read order.status but not order.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:

ServicePricing ModelEstimated Monthly Cost (10M requests)Supports
AWS API Gateway (REST)$3.50 per million requests$35REST, WebSocket
AWS API Gateway (HTTP)$1.00 per million requests$10HTTP proxy, cheaper
AWS AppSync$4.00 per million queries$40GraphQL managed
Kong Gateway (Cloud)Plan-based$150 - $500REST, gRPC, GraphQL
Apigee (Google)Plan-based$500 - $2,500REST, enterprise features
Azure API ManagementTier-based$150 - $2,500REST, 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.

A

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

Enjoyed this article?

Get more like this in your inbox. No spam, unsubscribe anytime.

Comments

Loading comments...

Leave a comment

Stay in the loop

New articles delivered to your inbox. No spam.