Welcome, Developer đź‘‹
Almost every system design conversation eventually lands on the same fork in the road: should this API be REST, GraphQL, or gRPC. The honest answer is that all three are good choices for the right problem and bad choices for the wrong one, so let’s dig into what each one is actually optimizing for, and just as importantly, where each one hurts once it’s in production.
REST: resources over HTTP
REST models your API as a set of resources, each with a URL, and lets HTTP verbs describe what you want to do to them. It’s the default for a reason, and the reason isn’t tradition. It’s that REST rides on top of infrastructure the entire internet already built.
GET /users/42
GET /users/42/orders
POST /users/42/orders
PATCH /orders/128
DELETE /orders/128
Take caching. A GET /users/42 response with a Cache-Control header and an ETag can be cached by the browser, by a CDN, and by any proxy in between, with zero work from you. Conditional requests with If-None-Match mean repeat fetches cost a 304 and no body at all. That’s an enormous amount of free performance, and it only works because REST leans on plain HTTP semantics. The verbs carry meaning too: GET and PUT are idempotent by contract, so clients and proxies can safely retry them, while POST isn’t, which is exactly why payment APIs bolt idempotency keys onto their POST endpoints.
A quick honesty check most posts skip: almost nobody builds textbook REST. The academic version includes hypermedia (HATEOAS), where responses carry links telling the client what it can do next. In practice, what we all call REST is “JSON over HTTP with sensible URLs and verbs,” and that’s fine. Just know that when someone at a whiteboard says REST, that’s what they mean.
The real long-term pain of REST isn’t the request model, it’s evolution. Once clients depend on a response shape, changing it is a breaking change, so you end up with /v1/ and /v2/ prefixes, or version headers, and a support matrix that grows every time. And the moment your client needs data spanning multiple resources, the model creaks. A mobile app rendering a profile with the last five orders and each order’s line items either makes several round trips, brutal on mobile networks where latency dominates, or the backend grows a bespoke /profile-screen endpoint that exists for exactly one screen. Multiply that by every screen and every client, and your clean resource model turns into a pile of view-shaped endpoints.
GraphQL: let the client ask for exactly what it needs
GraphQL flips the model. Instead of the server deciding what each endpoint returns, the client sends a query describing exactly the fields it wants, and the server resolves each field, potentially from different services or tables.
query {
user(id: "42") {
name
orders(limit: 5) {
id
total
lineItems {
productName
quantity
}
}
}
}One request replaces three or four REST calls, and the front-end team stops filing tickets for custom endpoints. GraphQL also solves the versioning problem more elegantly than REST does: fields are deprecated with @deprecated, clients migrate on their own schedule, and because every client declares exactly which fields it uses, you can measure when a deprecated field has zero traffic and actually delete it. There’s no /v2/. The schema just evolves.
Now the costs, because they’re real. Every field is a resolver, and a naive implementation of the query above runs a separate database query for every order and every line item. That’s the classic N+1 problem, and the standard fix is a batching layer like Dataloader that collects all the IDs requested in one tick and fetches them in a single query.
The second cost is one that doesn’t show up until someone malicious or careless finds your endpoint: the flexibility that makes GraphQL great also makes it a denial-of-service vector. Nothing stops a client from writing a deeply nested query that touches millions of rows, orders inside users inside orders inside users, ten levels deep. Production GraphQL needs query depth limits and query cost analysis, where each field has a weight and expensive queries get rejected before execution. If your GraphQL server doesn’t have this, you’ve shipped an unbounded query engine to the public internet.
The third cost is caching. Every GraphQL request is a POST to a single endpoint, so the entire HTTP caching stack that REST gets for free, browser cache, CDN, conditional requests, is gone. The production answer to both this and the security problem is persisted queries: clients register their queries ahead of time and send only a hash at request time. Arbitrary queries get rejected, and known queries become cacheable again because the hash is a stable key. If you run GraphQL at scale without persisted queries, you’re playing on hard mode for no reason.
GraphQL earns all this machinery when you have several client types, web, iOS, Android, pulling overlapping but different views of the same data, and a platform team willing to own the schema and resolver performance. For a small API with one client and a stable shape, you’re paying the tax without needing the flexibility.
gRPC: contracts and speed for service-to-service calls
gRPC is a different animal. It’s built on HTTP/2 and Protocol Buffers, and it’s designed for services talking to services, not browsers talking to backends. You define the contract in a .proto file, and both ends generate strongly typed client and server code from it.
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order);
rpc StreamOrderUpdates (OrderUpdateRequest) returns (stream OrderUpdate);
}
message GetOrderRequest {
string order_id = 1;
}
message Order {
string id = 1;
int64 total_cents = 2;
repeated LineItem items = 3;
}Two details in that file matter more than they look. First, total_cents as an integer, never a float, because money in floating point is a bug you only ship once. Second, those field numbers. They aren’t decoration. Protobuf serializes fields by number, not by name, which means you can rename a field freely, add new fields with new numbers, and old and new services keep talking to each other on the wire. Backward compatibility is designed into the encoding itself. The one rule is you never reuse a number from a deleted field, which is why you’ll see reserved 4; in mature proto files.
HTTP/2 is the other half of the story. It multiplexes many concurrent requests over a single connection, so one slow call doesn’t block the others behind it the way it would on HTTP/1.1. Combine that with protobuf’s compact binary encoding and you get meaningfully lower latency and bandwidth than JSON over HTTP/1.1, plus native streaming in both directions, which turns something like live order status updates from a polling hack into a first-class RPC.
gRPC also has an answer to a problem REST mostly ignores: cascading timeouts. Deadlines propagate. When service A calls B with a 200ms deadline and B calls C, that deadline travels with the request, and C stops working the moment the caller no longer cares about the answer. In a deep service graph, cancellation propagation is the difference between one slow dependency and a whole fleet burning CPU on requests nobody is waiting for.
The catch, and the one that surprises teams in production, is load balancing. Those efficient long-lived HTTP/2 connections are poison for L4 load balancers, which balance connections, not requests. One connection gets pinned to one backend, and suddenly a single pod eats all the traffic from a chatty client while its siblings idle. You need client-side load balancing or an L7 proxy that understands HTTP/2 streams, which is a big part of why service meshes like Istio and proxies like Envoy became standard kit alongside gRPC. Add the browser problem, no direct browser support without a grpc-web proxy, and binary payloads you can’t inspect with curl, and gRPC firmly belongs inside the data center, not at your public edge.
How to actually decide
The decision comes down to who is calling the API and what they need from it. Public API or anything a browser hits directly: REST, because the tooling, caching, and debuggability of plain HTTP are worth more than any efficiency gain. Multiple client types with meaningfully different data needs, plus a team that can own a schema: GraphQL, and take persisted queries and cost limiting seriously from day one. Internal service-to-service calls where latency, type safety, and streaming matter: gRPC, and budget for the load balancing story before you’re paged for it.
The styles also aren’t mutually exclusive within one system, and at scale they almost never are. The most common pattern I see is gRPC between internal services, with a REST or GraphQL layer at the edge translating for the outside world. So when this comes up in a system design interview, or in a real architecture review, the strong answer is rarely picking a winner. It’s putting the right style at each boundary and being able to say exactly which trade-off you’re buying at each one.
Stay focused, Developer!