API Paradigms Compared: REST vs. GraphQL vs. gRPC

API Paradigms: REST vs. GraphQL vs. gRPC
REST wins by default because your team already knows it and every tool speaks HTTP and JSON. That default is correct less often than most architecture decks admit. If your client is a mobile app screen that needs data from six resources, REST forces six round trips or a bespoke aggregation endpoint; GraphQL collapses that into one request the client shapes itself. If your client is another service on the same rack pushing 50,000 calls a second, GraphQL's parsing overhead is a tax you don't need, and REST's JSON-over-HTTP/1.1 stack is leaving performance on the table that gRPC picks up for free.
The three paradigms solve different problems, and the honest way to choose is to name your actual bottleneck — round trips, payload shape, or raw throughput — and pick the tool built for that bottleneck.
What REST actually constrains, not what people think it does
Roy Fielding's 2000 dissertation, the document everyone cites and almost nobody reads, defines REST through six constraints: client-server separation, statelessness, cacheability, a uniform interface, a layered system, and the optional code-on-demand constraint. Most production APIs that call themselves "RESTful" satisfy maybe three of these. The one people skip almost universally is hypermedia as the engine of application state (HATEOAS) — the requirement that a client should navigate the API through links returned in responses, not through URLs it has hardcoded in advance. Fielding was blunt about this on his own blog in 2008: an API that requires out-of-band knowledge of fixed URI structures isn't REST, regardless of what its documentation calls it.
That gap matters less than purists claim. What actually earns REST its keep in production is the stateless constraint paired with cacheability. Because a REST server holds no session context between requests, any request can be routed to any server behind a load balancer, and any intermediary — a CDN, a reverse proxy, a browser cache — can legally store and replay a response without asking permission first. This is why REST scales horizontally without effort: statelessness increases message size a little, because every request has to carry its own context, but Fielding's own analysis notes that caching more than pays that cost back.
Cache-Control and ETag are the actual payoff, and most teams half-implement them
The mechanism is two headers working together, not one. Cache-Control: max-age=3600 tells a client it can reuse a response for an hour without contacting the server at all — zero network cost during that window. ETag handles what happens after the window expires: the server computes a fingerprint of the resource (a hash or version number), the client stores it, and on the next request sends it back in an If-None-Match header. If the fingerprint still matches, the server replies 304 Not Modified with no body at all, instead of re-sending a payload that hasn't changed.
The distinction that trips people up is no-cache versus no-store. no-cache does not mean "don't cache" — it means "cache it, but revalidate with the server before serving it again." no-store is the one that actually forbids storage anywhere, which is what you want on an authentication endpoint or a banking transaction, never on a product catalogue. I've seen teams set no-cache on a login response thinking they'd blocked caching, when the browser was quietly storing the body and just adding a round trip to check freshness. If the goal is "never persist this," the header is no-store, full stop.
None of this exists in GraphQL or gRPC in any native form. A GraphQL response is a POST to a single endpoint, and HTTP caches key on URL and method — a POST body isn't part of the cache key, so a CDN can't distinguish one GraphQL query from another. gRPC drops HTTP semantics almost entirely in favor of its own framing over HTTP/2, so CDNs can't cache gRPC responses either. If your workload is read-heavy and largely public — a product catalogue, a documentation site, a public blog API — REST's free ride on HTTP caching infrastructure is a real, measurable advantage that the other two paradigms simply forfeit.
GraphQL: the client asks for exactly what it needs, and pays for it in complexity elsewhere
The problem GraphQL was built to solve has an official name in its own literature: over-fetching and under-fetching. Over-fetching is a REST endpoint returning a full user object — twenty fields — when the mobile screen only renders three of them. Under-fetching is the opposite: the screen needs a user, that user's five most recent orders, and each order's shipping status, so the client fires four or five sequential REST calls to assemble one view. A 2024 study published in the Journal of Informatics and Information Technology (JUTIF) ran controlled test scenarios comparing the two approaches directly and found GraphQL delivered response speeds 36.84% to 93.04% faster than REST in under-fetching scenarios — specifically once a view required calling more than four separate REST endpoints. For pure over-fetching, the same study found plain REST already gave adequate response speed on its own, and GraphQL's advantage there was smaller and conditional. That's a more honest picture than the usual GraphQL sales pitch: the win is concentrated in composite views assembled from many resources, not in trimming a few unused fields off a single small payload.
The mechanism is a single typed schema and a single endpoint. The client sends a query describing the exact shape of the response it wants:
One request, one round trip, and the response tree matches the query tree field for field. GraphQL's own documentation frames this as "flexible, versionless APIs powered by a strong type system" — versionless because adding a field to the schema never breaks an existing client, since old queries simply don't ask for the new field. That's a genuine advantage over REST versioning schemes, which usually end up as /v1/, /v2/ URL prefixes or Accept header gymnastics.
The N+1 problem is the tax GraphQL charges for that flexibility
Here's the failure mode nobody puts in the pitch deck. GraphQL resolvers are field-based: each field on each returned object can trigger its own data fetch. Query a list of twenty blog posts, and if each post's author field resolves with its own database call, you get one query for the list plus twenty more for the authors — twenty-one queries where a hand-written SQL join would have needed one. This is the N+1 problem, and it's not an edge case; it's the default behavior of a naive resolver implementation, and it shows up the moment you nest a relationship inside a list.
The standard fix is DataLoader, a batching utility that GraphQL.js documents directly: instead of each resolver hitting the database immediately, DataLoader queues .load(key) calls within the same event-loop tick and dispatches them as one batched batchLoadFn(keys) call. Twenty-one queries become two — one for the post list, one WHERE author_id IN (...) for every author needed. Apollo's own GraphOS documentation confirms this is the standard remediation even in federated, multi-service GraphQL setups, where the router batches entity references across subgraphs rather than resolving them one at a time.
The catch, documented repeatedly by people who've actually shipped this, is that DataLoader batching is fragile in a specific way: it only batches calls issued within the same scheduling window, so code that awaits each .load() call before issuing the next one defeats batching entirely and silently falls back to one-by-one fetching. I'd treat this as the single most common GraphQL production bug: it doesn't throw an error, it just quietly reintroduces N+1 under load, and you find out from a slow p99, not from a stack trace. DataLoader instances also need to be created fresh per request rather than reused globally, or you leak cached data between users — a subtle authorization bug hiding inside a performance optimization.
A GraphQL endpoint with no query limits is an open invitation to a denial-of-service query
Because the client controls the query shape, a malicious or careless client can nest nearly unbounded depth — friends of friends of friends of friends — and turn a five-line query into a resolver tree with tens of thousands of leaf nodes. The standard defenses, per current GraphQL security guidance, are query depth limiting, cost analysis (assigning a numeric cost to each field and rejecting queries above a threshold, with lists and joins weighted higher than scalar reads), and persisted queries, where the server only executes pre-registered, hashed queries rather than arbitrary client-submitted strings. That last one is worth being blunt about: a public GraphQL endpoint that accepts arbitrary queries from arbitrary clients, with introspection left on and no cost ceiling, is not a hardened API — it's a query planner exposed to the internet. Persisted queries essentially turn GraphQL back into a fixed set of endpoints for production traffic, which is a reasonable trade once you've used the schema's flexibility during development.
gRPC: binary framing for services that only talk to each other
gRPC skips HTTP semantics almost entirely and instead defines services as remote procedure calls, described in a .proto file compiled ahead of time into client and server code. The official introduction describes the model plainly: "a client application can directly call a method on a server application on a different machine as if it were a local object". The wire format is Protocol Buffers by default, and Google's own protobuf documentation is direct about the trade it's making: "It's like JSON, except it's smaller and faster, and it generates native language bindings".
Every field gets a number, not just a name, and that number — not the field name — is what's actually written to the wire. This is why protobuf messages are smaller than the equivalent JSON: no repeated key strings, no whitespace, no quotes, just tagged binary values. It's also why schema evolution is safe by design: protobuf's own documentation notes old code reads new messages by simply ignoring unrecognized field numbers, and new code reads old messages by falling back to documented default values for anything missing. You get backward and forward compatibility without a version header, as long as you never reuse a field number — reserve it explicitly if you delete a field, or a future field reusing that number will silently deserialize as the wrong type against old clients.
The benchmark numbers, and where they actually hold
A 2026 industry benchmark comparing gRPC against REST over JSON/HTTP1.1 found gRPC delivering 2.3ms p50 latency on 1KB payloads versus 10.1ms for REST, roughly a 77% reduction, with the gap narrowing to about 15% once payloads grow to 100KB, where the serialization saving matters proportionally less against the sheer bytes moved. The same benchmark measured p99 tail latency under high concurrency at 9ms for gRPC versus 34ms for REST — a 3.7x difference — which is arguably the more operationally relevant number, since tail latency under load is what triggers timeouts and cascading retries in a microservice mesh, not the median. Serialized payload size ran roughly 50–200 bytes for protobuf against 500–2,000 bytes for the equivalent JSON, close to the commonly cited "10x smaller" figure. The benchmark attributes the combined advantage to three separate mechanisms stacking: HTTP/2 multiplexing eliminating repeated connection setup, HPACK header compression cutting per-request header bytes, and protobuf parsing running roughly 6–10x faster than JSON parsing on the CPU side. It's worth being precise about the honest caveat buried in the same source: the gap shrinks substantially once REST is run over HTTP/2 with a binary content type like MessagePack or CBOR instead of plain JSON over HTTP/1.1 — the comparison that produces headline numbers like "77% faster" is gRPC against the most common REST configuration teams actually run, not against REST's best possible configuration.
Four streaming modes REST doesn't have natively
gRPC is built on HTTP/2 streams, and it exposes four calling patterns directly in the .proto definition: unary (one request, one response — the REST-equivalent case), server streaming (one request, a stream of responses), client streaming (a stream of requests, one response), and bidirectional streaming (both sides stream independently over the same connection). REST has no native equivalent to any of the streaming modes; teams bolt on Server-Sent Events or WebSockets to approximate them, which works but sits outside the REST resource model entirely.
Deadlines and status codes are part of the contract, not an afterthought
gRPC's error model is a fixed enumeration, not a loose convention like HTTP status codes reused across incompatible meanings. DEADLINE_EXCEEDED fires when a client-specified deadline passes before the server responds — and gRPC's own documentation flags a sharp edge here: a state-changing operation can return DEADLINE_EXCEEDED even after it has completed successfully server-side, if the response was simply delayed past the deadline. That's a real design implication, not a footnote: any gRPC method that mutates state needs to be idempotent or carry its own deduplication key, because "the client got a deadline error" and "the operation didn't happen" are not the same fact. gRPC also distinguishes UNAVAILABLE — some data was written to the connection before it broke — from DEADLINE_EXCEEDED — no data was transmitted before time ran out, a distinction useful for deciding whether a retry is safe.
The cost: gRPC doesn't run in a browser, and you can't read the wire format by eye
The single biggest practical limitation is browser support. The Fetch API doesn't expose raw HTTP/2 streams to JavaScript, so a browser tab literally cannot speak gRPC directly. The workaround, gRPC-Web, is a CNCF subproject that defines a browser-compatible wire format and routes traffic through an Envoy proxy that translates between gRPC-Web and native gRPC — but bidirectional streaming isn't supported through that translation layer, only server streaming is, and the proxy adds a network hop plus 50–200KB of JS runtime to your bundle. gRPC also can't be routed by HTTP/1.1 load balancers or cached by CDNs, since it isn't semantically HTTP in the way REST is, and debugging it means reading binary frames instead of a JSON body you can eyeball in a browser's network tab. None of this is a flaw exactly — gRPC was never designed for a browser client, it was designed for one internal service calling another where both ends run generated code from the same .proto file — but it does mean gRPC is the wrong choice the moment a public-facing browser client is anywhere in the request path.
Choosing between them without pretending it's close
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Client control over response shape | Fixed per endpoint | Full — client specifies fields | Fixed per RPC method |
| Round trips for composite views | One per resource | One total | One per RPC call |
| Browser-native | Yes, since 1995 | Yes | No — needs gRPC-Web + proxy |
| HTTP caching (CDN, browser) | Native via Cache-Control/ETag | Not cacheable by URL | Not cacheable |
| Payload format | Text (JSON/XML) | Text (JSON) | Binary (Protobuf) |
| Streaming | Bolted on (SSE/WebSockets) | Subscriptions (via WebSockets) | Native, 4 modes |
| Typical use | Public APIs, CRUD resources | Composite views, mobile/BFF layer | Internal service-to-service |
The pattern in that table is the actual decision rule. REST earns its place wherever a browser or third-party developer needs to hit your API directly, wherever caching infrastructure matters, and wherever the resource model is genuinely simple — CRUD on a handful of entity types. GraphQL earns its place specifically at the boundary between a client with a complex, variable data need — a mobile app rendering different screens with different data shapes — and a backend made of many services; it's why GraphQL shows up so often as a "backend for frontend" layer sitting in front of REST or gRPC services, not as the protocol those services use to talk to each other. gRPC earns its place inside the service mesh, between processes you control end to end, where both sides can share a compiled .proto contract and neither one is a browser.
The mistake I'd actively argue against is picking one paradigm as an organization-wide standard and forcing every boundary through it. A system with a public REST API, a GraphQL layer aggregating that API plus two other backends for a mobile client, and gRPC connecting the twelve internal services behind all of it isn't architectural inconsistency — it's three tools solving three different problems, which is what the problems actually call for.
