Pick REST when strangers will call your API and HTTP caches should do the heavy lifting. Pick GraphQL when you own several clients that each want a different slice of the same data. Pick gRPC when services talk to services and latency, typed contracts, and streaming matter more than reaching a browser. That's the short answer. The longer answer is that none of the three removes cost; each one moves it somewhere else, into caches, into schema governance, or into proxies and load balancers. The right choice is the one whose cost lands on a team that can carry it.
Which one to pick, in one table
Axis | REST | GraphQL | gRPC |
Contract | Optional (OpenAPI) | Required schema (SDL), introspectable | Required |
HTTP caching | Native: ETag, Cache-Control, CDN | Only with | Effectively none at the HTTP layer |
Evolution | URL or header versioning; additive changes | Additive fields plus | Field numbers plus |
Browser client | Native | Native | gRPC-Web via proxy; no client or bidirectional streaming |
Transport | HTTP/1.1, HTTP/2, HTTP/3 | HTTP/1.1, HTTP/2, HTTP/3 | HTTP/2 required; HTTP/3 only in some implementations |
Payload | Usually JSON | JSON | Protocol Buffers (binary) |
Streaming | SSE or WebSocket add-ons | Subscriptions; | Four native modes |
Debugging |
| GraphiQL, explorers |
|
Typical fit | Public APIs, CRUD, webhooks | Backend-for-frontend, product APIs serving many clients | Internal microservices, mobile backends with generated SDKs |
Everything below explains the rows that tend to decide real projects.
Caching: REST gets it free, GraphQL has to earn it, gRPC mostly skips it
REST's quiet advantage is that it's shaped like the web. A GET /products/42 response carries an ETag and Cache-Control, so browsers, CDNs, and reverse proxies can serve it without touching your origin. You don't write that code. You configure it.
GraphQL inverts the situation. Most clients send a POST to a single /graphql endpoint with the query in the body, and HTTP intermediaries see one opaque URL with an ever-changing payload. The fix is well understood but not free: hash each query at build time (persisted queries), send the hash with a GET, and let the CDN key on it. The GraphQL-over-HTTP draft spec reinforces this: GET is restricted to queries, mutations via GET return 405 Method Not Allowed, and the preferred application/graphql-response+json content type lets the server send real 4xx/5xx status codes instead of always returning 200. That last point matters for caching too; a cache can't reason about an error that arrives as HTTP 200.
GraphQL's real caching strength lives on the client. Normalized caches in Apollo Client, Relay, and urql de-duplicate entities across queries, so a product fetched in a list is already present when the detail view opens. That's a different kind of cache, and it solves a different problem, mostly mobile and single-page apps hammering the same graph.
gRPC largely opts out. Calls are POSTs to method paths with binary bodies over long-lived HTTP/2 connections, which no CDN will cache. Caching in gRPC systems happens in the application: Redis, in-process memoization, or a read-through layer in front of the service. If your traffic profile is "many anonymous readers, few writers," that alone should push you toward REST.
Schema evolution: who breaks whom when a field changes
All three can evolve without breaking clients, if you follow the rules. The rules differ in how much they're enforced.
REST has the weakest tooling and the most freedom. Versioning lives in the URL (/v2/), a header, or a media type, and nothing stops a developer from renaming a JSON key on a Tuesday. OpenAPI plus a contract-testing step in CI closes that gap, but it's an opt-in discipline, not a property of the architecture.
GraphQL refuses the idea of versions. The schema is the contract, clients ask only for fields they name, and removing a field is the only truly breaking change. The intended path is additive: add priceCents, mark price deprecated, watch field-usage metrics until traffic drops to zero, then remove it.
type Product {
id: ID!
name: String!
priceCents: Int!
price: Float @deprecated(reason: "Use priceCents. Scheduled for removal after 2027-03-31.")
}Because every client declares what it reads, a GraphQL server can tell you exactly which app versions still touch price. REST can't, unless you log and parse response bodies.
gRPC pushes evolution rules into the wire format itself. Protocol Buffers identify fields by number, not name, so renaming is safe and reordering is harmless. What breaks things is reusing a number with a different type. The reserved keyword exists to make that mistake a compile error.
edition = "2024";
package catalog.v1;
message Product {
reserved 3; // was legacy_sku (string); never reuse
reserved legacy_sku;
string id = 1;
string name = 2;
int64 price_cents = 4;
}The syntax line is worth a note. Protobuf Editions replace the proto2 and proto3 designations that we have used for Protocol Buffers. Editions won't break existing binaries and don't change a message's binary, text, or JSON serialization format. Edition 2024 requires protoc 32.0 or newer, released 23 May 2025. Buf, which maintains an independent Protobuf toolchain, reports that three editions have shipped so far, roughly one per year: edition 2023, which unified proto2 and proto3; edition 2024, which added symbol visibility and import option; and edition 2026 (protobuf 36.x), which turns on naming-style enforcement, defaults symbol visibility to strict, and adds custom JSON names for enum values. Treat the 2026 details as reported rather than confirmed from protobuf.dev; what's confirmed is that the wire format doesn't change between editions, which is why gRPC schema evolution stays safe across upgrades.
The syntax line is worth a note. Protobuf Editions replace the proto2 and proto3 designations that we have used for Protocol Buffers. Editions won't break existing binaries and don't change a message's binary, text, or JSON serialization format. Edition 2024 requires protoc 32.0 or newer, released 23 May 2025. Buf, which maintains an independent Protobuf toolchain, reports that three editions have shipped so far, roughly one per year: edition 2023, which unified proto2 and proto3; edition 2024, which added symbol visibility and import option; and edition 2026 (protobuf 36.x), which turns on naming-style enforcement, defaults symbol visibility to strict, and adds custom JSON names for enum values. Treat the 2026 details as reported rather than confirmed from protobuf.dev; what's confirmed is that the wire format doesn't change between editions, which is why gRPC schema evolution stays safe across upgrades.
Client requirements: browsers decide more than you'd like
This axis eliminates options faster than any other.
A browser can call REST and GraphQL with plain fetch. It cannot call native gRPC. Browsers cannot speak native gRPC, so gRPC-Web translates those calls into requests the browser can make. That translation supports unary and server-streaming calls, but it cannot provide client streaming or bidirectional streaming. Microsoft's ASP.NET Core documentation says the same for its stack: when using gRPC-Web, we only recommend the use of unary methods and server streaming methods. You'll also need either a server that understands gRPC-Web directly or a proxy such as Envoy in front, and calling gRPC services on a different domain requires CORS configuration on the server.
The gRPC-Web ceiling isn't about to lift. A September 2026 analysis of streaming on the web notes that the official gRPC-Web streaming roadmap has discussed this for years, but the project is no longer pursuing major features. If a browser needs full-duplex streaming against a gRPC backend, you're adding WebSockets or the Connect protocol, not waiting for gRPC-Web.
Mobile apps flip the calculation. Native iOS and Android clients speak HTTP/2 fine, generated Protobuf stubs give you typed SDKs for free, and smaller binary payloads help on poor networks. GraphQL also serves mobile well for a different reason: one round trip returns exactly the screen's data, which matters more on a 300 ms cellular link than the bytes saved by binary encoding.
Third-party developers are the final filter. If you don't control the clients, REST with an OpenAPI document is the only choice that doesn't require the caller to adopt your toolchain. GraphQL public APIs exist (GitHub and Shopify run large ones), but they ask integrators to learn a query language and manage query cost. Public gRPC APIs are rare outside cloud providers that also ship the SDKs.
Operational complexity: the day-two bill
Launch day is similar across the three. Day two is where they separate.
REST's operational load is spread thin and well understood. Any load balancer works, any gateway handles authentication and rate limits, and every observability vendor parses HTTP status codes and paths out of the box. Its main hidden cost is fan-out: a mobile screen that needs six resources makes six requests, and someone eventually builds a "backend for frontend" to collapse them, which is often the moment GraphQL enters the conversation.
GraphQL concentrates complexity in the server. Resolvers that fetch per item cause N+1 database queries unless you add batching (DataLoader or an equivalent). Arbitrary queries mean arbitrary cost, so you need depth limits, complexity scoring, or an allowlist of persisted operations before a public endpoint goes live. Error handling needs attention because a response can be partially successful. And federation, when several teams own slices of one graph, adds a router tier that must be owned, upgraded, and monitored like any other service.
gRPC concentrates complexity in the network layer. HTTP/2 multiplexes many calls over one long-lived connection, which is exactly what a layer-4 load balancer can't spread evenly: one backend receives every call from a client until the connection drops. Production gRPC needs either client-side load balancing (xDS, service discovery) or a layer-7 proxy such as Envoy that balances per request. Deadlines, retries, and keepalives are configurable per call and must be set deliberately; the defaults rarely match your SLOs. Debugging is also harder. Payloads are binary, so you need server reflection and tools such as grpcurl, and in a browser the Chrome DevTools Network tab will show you that a gRPC-Web request was made and how many bytes were transferred, but it has no idea what those bytes mean. The response body is raw binary, and there is no built-in way to inspect the envelope framing or decode the protobuf payload.
Tooling has caught up a lot since 2020, but the shape of the bill hasn't changed. REST spreads cost across many small, familiar pieces; GraphQL stacks it on the server team; gRPC stacks it on platform and networking. Match that to the team you actually have.
Network performance: what benchmarks show and hide
The mechanical facts are not in dispute. Protocol Buffers put field numbers, not field names, on the wire, so a gRPC message is smaller than the equivalent JSON and cheaper to parse. HTTP/2 lets gRPC run thousands of calls over one connection without per-request handshakes. GraphQL cuts round trips by returning a whole screen's worth of data in one response. REST over HTTP/1.1 pays for every round trip and every repeated header, though REST over HTTP/2 or HTTP/3 removes much of that.
What vendor benchmarks hide is that the gaps depend entirely on message shape and where latency actually sits. For a service that returns ten small numeric fields, binary encoding wins outright. For one that returns a 2 KB text description, the JSON overhead is a rounding error next to database time. For a mobile client on a high-latency link, GraphQL's single round trip beats both alternatives regardless of encoding, because the dominant cost is waiting, not parsing. Any throughput claim you read, including the "N times faster" headlines, should be checked for payload size, HTTP version, serialization library, and whether the server did any real work per request.
HTTP/3 deserves a time-stamped note because it's often cited as a gRPC advantage. The design exists: the gRPC project's G2 proposal for gRPC over HTTP/3 is listed as Status: Implemented, Implemented in: grpc-dotnet, and if the server doesn't support HTTP/3 then the client continues to use HTTP/2. HTTP/3 requires TLS and will never be used by an insecure channel. But it isn't ecosystem-wide. gRPC HTTP/3 support is still experimental and not finalized across all gRPC implementations. Some language-specific libraries have preview or experimental support, but the official gRPC specification for HTTP/3 transport remains a work in progress. In Go, the path runs through ConnectRPC rather than grpc-go: a September 2026 follow-up reports that quic-go gained HTTP trailer support, which now enables HTTP/3 support for ConnectRPC for gRPC, gRPC-Web and Connect for both client and server. While I'd recommend deploying in production with a more established load balancer that supports HTTP/3, quic-go is perfect for experimentation and development. On the proxy side, Envoy's upstream HTTP/3 support is newer and called out as alpha in docs. As of October 2026, treat gRPC over HTTP/3 as available in .NET and ConnectRPC, experimental elsewhere, and not a reason to pick gRPC by itself.
GraphQL's own performance roadmap is also unfinished. The specification's latest release is September 2025, with a working draft dated September 28, 2026, and a September 2026 edition pull request was open in the spec repository at the time of writing. The reference implementation GraphQL.js v17.0.0 shipped on 15 June 2026, and incremental delivery via @defer and @stream remains experimental rather than part of the finalized spec, so don't plan an architecture around it yet.
The decision tree most teams end up following
Put the four axes together and the choice usually resolves in three questions: who calls you, how many different data shapes they need, and whether latency or streaming is a hard requirement.
The tree is deliberately biased toward REST at every "no." That's not nostalgia. REST is the option whose failure modes are cheapest to fix later, and a team that outgrows it usually knows exactly why.
Running two of them on purpose
The most common production shape isn't a winner-take-all choice. It's gRPC inside the cluster, with REST or GraphQL facing outward.
That split works because each layer gets the properties it needs. Internal calls benefit from typed contracts, generated clients in every language, and streaming; nobody internal cares that a CDN can't cache them. The edge needs browser compatibility, caching, and a contract third parties can read. Tools exist to bridge the two without duplicating logic: grpc-gateway generates a REST and OpenAPI facade from .proto annotations, the Connect protocol serves gRPC, gRPC-Web, and plain HTTP/JSON from one handler, and GraphQL gateways routinely resolve fields by calling gRPC services underneath.
The hybrid has its own cost, and you should name it before committing: two contracts to keep in sync, two sets of tooling to maintain, and two places where an error can be translated badly. Teams that do this well generate the outer contract from the inner one instead of hand-writing both.
Questions developers ask before committing
Can GraphQL replace REST for a public API? It can, and GitHub and Shopify prove it at scale, but it asks more of integrators. Callers must learn the query language, your side must enforce query cost limits, and HTTP caching only works once persisted queries and GET are in place. For a public API whose consumers are mostly scripts and integrations, REST with OpenAPI remains the lower-friction default.
Does gRPC work in a web browser? Not natively. Browsers can't control HTTP/2 framing or read trailers, so you use gRPC-Web, which needs a compatible server or a proxy such as Envoy. gRPC-Web supports unary and server-streaming calls only; client streaming and bidirectional streaming are not available from the browser. For full-duplex browser traffic, pair gRPC with WebSockets or use the Connect protocol.
Is gRPC always faster than REST? For small, frequent service-to-service calls, usually yes: binary Protocol Buffers encode and parse faster than JSON, and HTTP/2 multiplexing removes per-request overhead. For large text payloads or requests dominated by database time, the difference is marginal. On high-latency mobile links, cutting round trips (GraphQL's strength) often matters more than encoding.
How do you version a GraphQL API? You mostly don't. Add new fields alongside old ones, mark the old ones with @deprecated and a reason, track per-field usage from client queries, and remove a field only when usage reaches zero. The schema is the version. The trade-off is that you can never make a clean break; deprecated fields can linger for years if one client never upgrades.
Should a new microservices project start with gRPC? Start with gRPC if your services are in more than one language, you need streaming, or call volume makes serialization cost visible. Start with REST or JSON over HTTP if the team is small, services share a language, and nobody has run HTTP/2 load balancing before. Switching internal calls later is far cheaper than switching a public contract.
What to choose today
If you're exposing an API to people you'll never meet, choose REST and document it with OpenAPI. If you own a handful of clients with different screens and you're tired of building bespoke aggregation endpoints, choose GraphQL at the edge and put cost limits on it before anyone else does. If services are calling services and you want typed contracts, generated clients, and streaming, choose gRPC inside the network, and plan for a layer-7 proxy from the start.
Most systems past a certain size end up with two of these on purpose. That's not indecision; it's each layer getting the properties it needs. The mistake isn't picking the "wrong" one. It's picking one for a benchmark headline when the cost it moves lands on a team that has no way to pay it.





Discussion
Loading discussion…