Choosing an API style is not a contest between three interchangeable syntaxes. REST over HTTP, GraphQL, and gRPC expose different contracts, put complexity in different places, and fit different traffic paths. A public product API, a mobile screen aggregator, and a low-latency service-to-service stream may all belong to the same system while needing different interfaces.
The useful question is therefore not “Which technology is best?” It is “Which set of trade-offs is acceptable at this boundary?” Start with consumers and failure modes, then evaluate semantics, delivery constraints, operations, and team ownership. The result may be one protocol, but a mixed architecture is often the more coherent answer.
Start with the contract, not the transport
All three approaches can define a strong contract, but they make different ideas primary.
| Style | Contract center | Natural unit | Typical source of truth | Main design risk |
|---|---|---|---|---|
| REST | Resources and HTTP semantics | Representation at a URI | OpenAPI plus server implementation | Treating every operation as an arbitrary POST |
| GraphQL | A typed, traversable application graph | Fields selected by a client | GraphQL schema and resolvers | Exposing a convenient graph without cost boundaries |
| gRPC | Remote methods and typed messages | Unary or streaming RPC | .proto files |
Coupling clients too closely to internal service methods |
A well-designed REST contract uses the existing semantics of HTTP. Safe reads use GET; creation commonly uses POST; replacement and partial update use PUT and PATCH; deletion uses DELETE. Status codes, conditional requests, media types, and cache headers are part of the contract rather than decoration. OpenAPI describes that contract for documentation, validation, and code generation:
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/orders/{orderId}:
get:
operationId: getOrder
parameters:
- name: orderId
in: path
required: true
schema: { type: string }
responses:
"200":
description: Order found
headers:
ETag:
schema: { type: string }
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"404":
description: Order does not exist
components:
schemas:
Order:
type: object
required: [id, status, total]
properties:
id: { type: string }
status: { type: string, enum: [pending, paid, shipped] }
total: { type: number, minimum: 0 }
GraphQL makes the selectable type graph primary. The schema below lets a client request an order and only the fields it needs. Nullability is significant: Order! means a returned order value cannot be null, while order(id: ID!): Order still permits “not found” as null.
type Query {
order(id: ID!): Order
}
type Order {
id: ID!
status: OrderStatus!
total: Money!
items(first: Int = 20, after: String): OrderItemConnection!
}
type OrderItemConnection {
nodes: [OrderItem!]!
endCursor: String
hasNextPage: Boolean!
}
type OrderItem {
productId: ID!
quantity: Int!
}
type Money {
amount: String!
currency: String!
}
enum OrderStatus {
PENDING
PAID
SHIPPED
}
The connection is not incidental. Pagination, authorization, nullability, and field cost must be designed into the graph. GraphQL removes endpoint proliferation, not domain complexity.
Protocol Buffers describe messages and RPC methods independently from a particular programming language. Numeric field identifiers are the wire contract and must remain stable:
syntax = "proto3";
package commerce.v1;
message GetOrderRequest {
string order_id = 1;
}
message Order {
string id = 1;
string status = 2;
int64 total_minor_units = 3;
string currency = 4;
}
service OrdersService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc WatchOrders(WatchOrdersRequest) returns (stream Order);
}
message WatchOrdersRequest {
repeated string order_ids = 1;
}
This contract supports generated clients and compact binary messages. It does not imply that an RPC is a local function call: deadlines, cancellation, retries, partial failure, and backward compatibility still cross a network boundary.
Tip
Judge contract quality separately from protocol choice. An explicit OpenAPI document can be stronger than an undisciplined GraphQL schema, and a carefully evolved GraphQL schema can be safer than RPC methods that mirror unstable implementation details.
Match the topology and the clients
The path between consumer and owner often decides more than payload syntax. Browsers speak ordinary HTTP extremely well, but they do not generally expose raw HTTP/2 framing to JavaScript. Native gRPC therefore needs a compatible proxy such as Envoy and gRPC-Web, or a JSON/GraphQL gateway. That extra hop affects streaming modes, headers, error translation, deployment, and debugging.
REST is usually the lowest-friction choice for public APIs, webhooks, simple browser clients, file transfer, and integrations that benefit from curl, proxies, CDNs, and standard HTTP tooling. Its resource model also creates stable organizational boundaries: consumers depend on an order representation, not on the current set of internal service calls.
GraphQL is strongest when one product surface needs to compose data from several domains and client views vary substantially. A mobile team can request a smaller shape than a desktop dashboard without asking the backend for a new endpoint. That flexibility is valuable only when the graph has clear ownership, batching, pagination, authorization, and demand controls. Otherwise the gateway becomes an opaque distributed join engine.
gRPC fits controlled service-to-service networks, polyglot generated clients, high request rates, and long-lived streams. Unary RPC is excellent for typed commands and queries. Server, client, and bidirectional streaming are first-class in the protocol, subject to language and proxy support. For asynchronous facts that need replay, fan-out, or durable retention, an event broker is usually a better abstraction than keeping an RPC stream alive indefinitely.
Compare behavior under production load
Performance is workload-specific. A tiny protobuf payload often encodes and transfers faster than equivalent JSON, while HTTP/2 multiplexing avoids many connection costs. Yet a database query, cross-region hop, or N+1 resolver pattern can dominate serialization by orders of magnitude. Benchmark complete representative calls at realistic concurrency; do not select a protocol from payload-size microbenchmarks alone.
| Concern | REST | GraphQL | gRPC |
|---|---|---|---|
| Caching | Native HTTP caching with URI, method, Cache-Control, ETag, and Vary |
Usually application/client normalized caches; persisted GET queries can use CDNs |
Commonly application-side; not naturally shared by HTTP caches |
| Errors | HTTP status plus a stable body such as Problem Details | HTTP may be 200 with data and errors; partial data is possible |
Canonical status code, message, and typed status details |
| Streaming | Chunked responses, SSE, WebSocket, or newer HTTP patterns; semantics vary | Subscriptions typically use WebSocket or SSE; queries may use incremental delivery where supported | Unary, client, server, and bidirectional streaming are native |
| Payload | Usually JSON, easy to inspect but verbose | Usually JSON and shaped by the selection set | Protobuf is compact and schema-bound |
| Browser reach | Native | Native over HTTP | Requires gRPC-Web, Connect, or a gateway |
| Load control | Rate limits by route, identity, and method | Requires depth, breadth, complexity, timeout, and pagination limits | Deadlines, message limits, concurrency limits, and flow control |
Caching illustrates the semantic difference. GET /products/42 has a stable cache key that a browser, reverse proxy, and CDN understand. GraphQL commonly sends many operations to one endpoint, so shared caching needs persisted operation identifiers, canonical variables, or resolver-level caches. Client normalized caches can be powerful but require globally stable object identities and careful mutation invalidation. gRPC usually relies on local caches or dedicated caching layers because generic HTTP caches do not understand method messages.
Error models also influence client behavior. REST should map protocol outcomes honestly and can use RFC 9457 Problem Details for machine-readable failures. GraphQL distinguishes transport failure from execution results and may return usable partial data alongside field errors. Clients must not treat every HTTP 200 as success. gRPC provides canonical statuses such as INVALID_ARGUMENT, NOT_FOUND, and UNAVAILABLE; retry policy must additionally inspect idempotency, deadline, and server guidance.
Warning
Automatic retries are safe only when the operation is idempotent or carries an idempotency key. Retrying a timed-out payment mutation, REST POST, or gRPC command without deduplication can apply the side effect twice.
Streaming adds lifecycle obligations: bound message sizes and buffers, propagate cancellation, enforce deadlines, authenticate reconnected clients, and define resume behavior. Backpressure within one HTTP/2 connection does not protect a downstream database from excessive concurrent streams.
Design evolution and observability together
Compatibility rules should be written before the first breaking change. REST teams often version a representation through a path, media type, or explicit compatibility policy. Additive response fields are generally safe for tolerant readers, but changing meaning, removing fields, narrowing accepted input, or changing identifiers is breaking. OpenAPI diff checks and consumer contract tests can enforce the policy.
GraphQL usually evolves one schema: add fields and enum values carefully, deprecate old fields with reasons, measure usage, migrate known clients, then remove only after an agreed window. Adding an enum value can still break clients that generated exhaustive switches. Schema registries and operation checks reveal whether a proposed change affects persisted production queries.
Protobuf evolution depends on field numbers. Never reuse a removed number or name; reserve it. Add fields with new numbers, preserve wire types, and avoid changing a unary method into a streaming method. Package versions such as commerce.v1 make deliberate breaking revisions visible. Generated clients improve ergonomics and type safety, but publishing code for several languages, pinning runtime versions, and handling skew become part of the platform commitment.
Observability must recover the operation identity hidden by infrastructure. Record low-cardinality route templates, GraphQL operation names or persisted hashes, and fully qualified gRPC methods. Propagate trace context and deadlines across gateways. Measure request count, latency, error rate, saturation, payload size, and cancellation. For GraphQL, add resolver timings, fan-out, query complexity, and data-source calls. For streams, record active streams, message rates, duration, reconnects, and termination status.
Do not put raw GraphQL queries, arbitrary URLs, customer IDs, or protobuf payloads in metric labels. They create unbounded cardinality and may leak sensitive data. Keep detailed values in sampled, access-controlled logs or traces.
Use constraints to build the decision matrix
A scorecard makes assumptions reviewable. Weight each criterion from 1 to 5 for the boundary, score each option from 1 to 5, multiply, and document evidence. The numbers do not create truth; they expose disagreements that a prototype or load test can resolve.
| Criterion | Prefer REST when… | Prefer GraphQL when… | Prefer gRPC when… |
|---|---|---|---|
| Consumer population | Partners and unknown clients need durable, ubiquitous HTTP | Product clients are known and their view shapes change often | Both ends are controlled services |
| Domain shape | Resource operations and HTTP semantics are clear | Consumers traverse and compose a connected graph | Commands, typed methods, and message flows are clearer |
| Caching | CDN and intermediary caching are central | Client entity caching or resolver caching is sufficient | Explicit service-local caching is acceptable |
| Streaming | SSE/WebSocket or downloads cover the need | Product subscriptions fit gateway support | High-rate or bidirectional streams are core |
| Latency/throughput | JSON overhead is insignificant | Fewer composed round trips offset resolver cost | Compact messages and sustained RPC volume matter |
| Governance | OpenAPI review and endpoint ownership already work | A schema registry and graph ownership can be funded | Protobuf review and generated-client distribution can be funded |
| Debugging | Human-readable traffic and basic tools are important | Graph explorers and operation tooling fit the team | Binary-aware tooling and distributed tracing are mature |
| Browser/edge | Direct compatibility is mandatory | Direct HTTPS plus a BFF/gateway is natural | A translation proxy and its limitations are acceptable |
Team capability is a production constraint, not an embarrassment. GraphQL requires people who can prevent N+1 queries, design field authorization, bound query cost, and operate schema changes. gRPC requires protobuf governance, code-generation pipelines, proxy knowledge, and tracing across binary calls. REST requires disciplined resource modeling, correct HTTP behavior, and resistance to endpoint inconsistency. Choose the complexity the owning team can sustain at 03:00, not merely demonstrate in a workshop.
Run a thin vertical prototype when two options remain close. Include authentication, one representative read, one write, one failure, tracing, deployment through the real gateway, and a production-shaped load test. The difficult integration points are more discriminating than a broad feature checklist.
Compose protocols at deliberate boundaries
One protocol does not need to escape every layer. A common architecture presents REST to partners, GraphQL to first-party applications, and gRPC between internal services. The edge is an anti-corruption layer: it translates identity, deadlines, errors, and domain representations without leaking every internal method.
type OrderView = {
id: string;
status: "PENDING" | "PAID" | "SHIPPED";
total: { amount: string; currency: string };
};
type GrpcOrder = {
id: string;
status: OrderView["status"];
totalMinorUnits: bigint;
currency: string;
};
interface OrdersGrpcClient {
getOrder(
request: { orderId: string },
options: { signal: AbortSignal; deadlineMs: number },
): Promise<GrpcOrder>;
}
declare function isGrpcNotFound(error: unknown): boolean;
function formatMinorUnits(value: bigint): string {
const sign = value < 0n ? "-" : "";
const absolute = value < 0n ? -value : value;
return `${sign}${absolute / 100n}.${(absolute % 100n)
.toString()
.padStart(2, "0")}`;
}
async function orderResolver(
_parent: unknown,
args: { id: string },
context: { orders: OrdersGrpcClient; signal: AbortSignal },
): Promise<OrderView | null> {
try {
const order = await context.orders.getOrder(
{ orderId: args.id },
{ signal: context.signal, deadlineMs: 800 },
);
return {
id: order.id,
status: order.status,
total: {
amount: formatMinorUnits(order.totalMinorUnits),
currency: order.currency,
},
};
} catch (error) {
if (isGrpcNotFound(error)) return null;
throw error;
}
}
The adapter deliberately maps gRPC NOT_FOUND to GraphQL nullability, carries cancellation and a deadline, preserves the int64 amount as bigint, and returns a product-facing type instead of exposing the generated message.
Avoid accidental protocol chains. A GraphQL request that fans out through a REST gateway into several gRPC services can multiply retries, deadlines, and telemetry gaps. Give each boundary an owner, publish its compatibility policy, cap fan-out, and keep the translation shallow.
Takeaways
- Choose per boundary and consumer, not once for the entire organization.
- Use REST when ubiquitous HTTP semantics, public integration, caching, and operational simplicity dominate.
- Use GraphQL when known product clients need flexible composition and the team can govern query cost and schema evolution.
- Use gRPC when controlled services need generated contracts, compact high-volume calls, or first-class streaming.
- Evaluate errors, retries, deadlines, caching, observability, and compatibility before comparing happy-path syntax.
- Treat browser gateways, proxies, generated-client distribution, and team expertise as architectural costs.
- Prefer a deliberate mixed architecture when external and internal constraints differ, with explicit adapters that preserve domain meaning.