System Design
API Design: REST, gRPC, GraphQL
Three protocols, three philosophies about how clients should talk to servers. When each one wins, and why "use REST" stopped being the universal answer.
API design: REST, gRPC, GraphQL
The first API I ever shipped was a REST endpoint that returned a
JSON blob with twenty-eight fields, of which the mobile app
needed three. The mobile team filed a ticket asking us to add a
?fields=... parameter to trim the payload. The web team
asked for ?include=customer,address to add more fields. The
analytics team asked for a separate endpoint that returned the
same data but in a flat shape suitable for CSV export. Within six
months, the "simple REST" endpoint had grown four query
parameters, an Accept header switch, and a third of the
codebase was format-conversion logic.
That progression is roughly how every team eventually rediscovers why the API-design question matters. The choice between REST, gRPC, and GraphQL isn't about which protocol is "modern" — it's about which set of trade-offs you want to opt into for the next five years of the system's life. Each one optimizes for a different shape of client, a different scale of team, and a different definition of "good enough".
REST: the lingua franca, and what it's actually good at
REST as we use it today — JSON over HTTP, resources as nouns,
verbs as HTTP methods — became dominant because it's the most
boring possible answer. Every language has an HTTP client and a
JSON parser. Every browser speaks HTTP natively. You can
curl a REST API from the command line and see what it does.
The barrier to entry is roughly zero, and that's why thirty years
into its life, REST is still the default for public APIs.
REST shines when your clients are heterogeneous (web, mobile, third-party integrations, scripts, scraping tools), when the "resource" model maps cleanly to your domain, and when caching matters — HTTP intermediaries (CDN, proxies, browsers) understand GET semantics and will cache them for you. The Stripe API, the GitHub API, every public SaaS API you've ever called: REST.
Where REST starts to creak is over-fetch and under-fetch. The
classic mobile-team complaint — "we need three fields and you
return twenty-eight" — is the over-fetch problem; bandwidth and
parse time both suffer. The classic mobile-team complaint #2 —
"we need to call GET /orders/42 and then GET /customers/X
and then GET /addresses/Y to render one screen" — is the
under-fetch problem; latency suffers because each round-trip
costs 100-300 ms on cellular. Versioning is also famously
awkward; /v1/ URL prefixes work but lock you into a
maintenance treadmill across three or four versions.
gRPC: typed, binary, fast, opinionated
gRPC takes the opposite design stance. Instead of "everything is a resource, every operation is GET/POST/PUT/DELETE", you define your service in a .proto file as typed methods:
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
rpc StreamOrderUpdates(StreamRequest) returns (stream OrderUpdate);
}
The .proto file is the source of truth. From it you generate strongly-typed client code in every language you care about (Go, Java, Python, TypeScript, Swift, Kotlin) and the runtime handles serialization (Protocol Buffers — a binary format much smaller and faster to parse than JSON), connection management, streaming, and retries. The wire format is HTTP/2 with multiplexed streams, so you can have many in-flight requests on a single connection without head-of-line blocking.
The wins are dramatic in the right context. Internal microservice traffic at Netflix, Google, Uber, Square: almost universally gRPC. The reasons stack up: 5-10× smaller payloads than JSON, schema-enforced contracts that catch breaking changes at compile time, native streaming for things like log tailing or live updates, and code generation that makes "calling another service" feel like calling a local function.
The losses are equally real. Browsers can't speak raw gRPC (HTTP/2
trailer-based metadata isn't exposed to JavaScript), so you need
either gRPC-Web (a slightly hobbled variant) or a gateway that
translates JSON to gRPC. Tooling is heavier — you need a .proto
compiler in your build pipeline. Debugging requires either a gRPC-
aware client like grpcurl or a UI like BloomRPC; you can't
just curl and read the output. And when you need to ship a
public API that arbitrary third parties will consume, gRPC asks
a lot more of them than REST does.
What the payload sizes actually buy you
The "5-10× smaller payloads" line gets repeated until it's abstract. Put real numbers on it. Take a modest order object — a dozen fields, a couple of nested line items — and look at what each protocol puts on the wire, then what that costs at scale: say an internal service fanning out 50,000 of these per second.
| Protocol | Bytes/order (typical) | Wire throughput at 50k/s | Relative |
|---|---|---|---|
| REST / JSON (pretty) | ~1,400 B | ~70 MB/s (560 Mbit/s) | 1.0× |
| REST / JSON (minified) | ~900 B | ~45 MB/s | 1.6× |
| gRPC / Protobuf | ~180 B | ~9 MB/s (72 Mbit/s) | 7.8× |
The numbers are illustrative, not a benchmark — your fields decide the real ratio — but the shape holds: Protobuf drops the field names from every message (the schema already knows them) and packs integers as varints instead of decimal ASCII. At 50k/s the JSON path is pushing more than half a gigabit just in serialized bodies; the Protobuf path fits in a tenth of that. That's the difference between one NIC and a fan-out problem.
The catch the table hides: this only matters when bytes are your bottleneck. For a public API serving 50 requests/second, nobody cares that JSON is 8× fatter — the win is rounding error against the cost of asking third parties to install a protoc toolchain. The payload-size argument is an internal-traffic argument.
GraphQL: one endpoint, the client decides the shape
GraphQL emerged from Facebook's mobile-team frustration with the over-fetch/under-fetch problem REST creates. Instead of the server defining which fields go in which response, the client sends a query that specifies exactly what it wants:
query GetOrderForMobile {
order(id: 42) {
id
total
items { name, qty }
}
}
The server returns exactly those fields, in exactly that shape. No over-fetch (mobile gets only what it asked for). No under- fetch (the query can traverse relationships in one round-trip). The web client and the mobile client can hit the same endpoint with different queries and each get a payload tailored to their needs, all from a single backend.
GraphQL's appeal is clearest when you have many client types with very different data needs (mobile-thin, web-rich, embedded- device-minimal) and when those needs evolve faster than you can ship new REST endpoints. Shopify, GitHub (their v4 API), Airbnb, and most of the modern frontend ecosystem (Apollo, Relay, URQL) live in this world.
The hidden costs are real and don't show up in the demos. Query complexity is unbounded by default — a malicious client can write a query that joins ten levels deep, and your server has to either limit query depth (frustrating legitimate complex queries) or accept that some requests will take forever. Caching is harder than REST because every query is a different POST request; you can't rely on HTTP-level caching and have to implement query-level caching yourself (Apollo Client, URQL). Server-side resolution is N+1 by default — a query for a list of orders with their customers naively triggers one DB query per order; you need DataLoader-style batching to make it efficient. And the type system, while powerful, requires real investment to get right (federation, schema stitching, breaking-change detection).
A GraphQL query for 100 orders, each with its customer, looks
like one request to the client. Resolved naively, it fires 1
query for the orders and then 100 more for the customers — 101
database round-trips behind a single innocent-looking POST. The
fix is DataLoader-style batching, which coalesces the 100
customer lookups into one WHERE id IN (...). The trap is that
the unbatched version works fine in the demo with three orders
and falls over at production fan-out.
Side-by-side, the way I actually compare them
| Aspect | REST | gRPC | GraphQL |
|---|---|---|---|
| Wire format | JSON over HTTP/1.1 | Protobuf over HTTP/2 | JSON over HTTP/1.1 (usually POST) |
| Payload size | Verbose | 5-10× smaller | Tailored per query |
| Schema | OpenAPI optional, often stale | .proto enforced | SDL enforced |
| Codegen | Per-tool, varying quality | First-class, every lang | First-class, every lang |
| Streaming | Server-Sent Events / WebSocket | Native bidi-streaming | Subscriptions (via WS usually) |
| Browser native | Yes | No (needs gRPC-Web bridge) | Yes |
| Cache friendliness | High (HTTP caching) | Low | Low (POST queries) |
| Easy to debug | curl + jq | grpcurl + Bloom | Apollo Studio / Postman |
| Best for | Public APIs, simple CRUD | Internal microservices | Many clients, varied needs |
| Worst at | Mobile efficiency, deep object graphs | Public APIs, browsers | Caching, query-complexity safety |
The decision the way teams actually make it
The honest decision rule, after watching dozens of teams pick:
- Public API for third parties? REST. Always. The cost of asking external developers to learn gRPC or write GraphQL queries dwarfs any technical win.
- Internal traffic between your own services, written by the same team? gRPC. The schema-as-contract win is huge; the performance wins are real but secondary.
- Mobile or many-client app where the same backend serves drastically different views? GraphQL. The client-shapes-the- response model is genuinely transformative for mobile bandwidth and round-trips.
- Just shipping a CRUD admin panel, an MVP, or anything where the API is a one-week investment? REST. The boring answer saves you a quarter of engineering time you can spend on product.
Most production systems end up running all three. The public- facing API is REST. The internal service mesh is gRPC. The mobile and web frontends consume a GraphQL gateway that fans out to gRPC under the hood. That's not architecture astronaut talk — it's what Uber, Airbnb, GitHub, and most of the modern web literally do. The patterns are complementary, not competing.
The protocol you pick is a five-year commitment. Pick by who's on the client side, not by what's fashionable on the server side.
Versioning, which everybody underestimates
The other axis of "good API design" that's independent of protocol is versioning. The bad news is that there is no versioning strategy that doesn't hurt; the good news is that each protocol has a conventional answer:
- REST:
/v1/ordersvs/v2/ordersin the URL is the most common; the alternative isAccept: application/ vnd.company.v2+jsoncontent-negotiation. Both leave you running multiple versions side-by-side until clients migrate. - gRPC: the protobuf schema's wire format is forward- and backward-compatible if you follow the rules (only add fields, never reuse field numbers). Most teams never explicitly version gRPC services; they just evolve the schema and rely on protobuf's compatibility guarantees.
- GraphQL: by design, you never break the schema. You add new
fields, mark old ones
@deprecated, and never delete a field that any client still queries. The tradeoff is schema bloat over time and the need for monitoring of deprecated field usage.
The version strategy that actually works long-term is one most teams hate: pick a discipline and never break it. URLs versioned once, .proto fields never reused, GraphQL fields never deleted. The pain of those constraints is much smaller than the pain of "v2 of our API broke our biggest customer".
A war story about a too-clever endpoint
A team I worked with built an order-status endpoint at
GET /orders/:id that returned { "status": "pending" | "paid" | "shipped" | "delivered" }. Six months in, the product
asked to add sub-states: "partially-shipped", "out-for-delivery",
"failed-delivery". The team added them to the enum. Mobile clients
on older versions crashed because they did a switch (status)
with no default case.
The fix the team eventually adopted is the rule I'd recommend
internalizing: every enum field in a public API is a versioning
event. Either widen the contract upfront ("the client must
treat unknown values as one of these defaults"), wrap the value
in a richer object ({ "status": "partially-shipped", "categoryHint": "shipped" }), or version the endpoint when you
add new values. Anything else is a future production incident
waiting to fire.
When you don't need to think about API design
If you're writing a single-process script, an internal tool used by three engineers, or a prototype that won't survive the quarter — REST + JSON is the right call by virtue of being the fastest to ship and least to debate. Don't optimize a Postgres query before you've measured; don't optimize an API protocol before you've shipped.
[CONCEPT]rate-limiting is what protects every API style from being abused. [CONCEPT]observability is what lets you see whether your API choice is actually working for your users.