gRPC vs REST vs GraphQL vs everything else — battle
Companion piece: this one compares RPC against the things that aren’t RPC. For the RPC family itself — CORBA, RMI, JSON-RPC, Twirp, Cap’n Proto and why gRPC vs tRPC is a category error — see gRPC vs tRPC vs every other RPC.
“Should we use gRPC or REST?” is one of those questions that sounds like a technology choice and is almost always a consumer choice wearing a costume. The wire format is the last thing you should decide and the first thing everybody argues about.
Here’s the tell. Nearly every one of these protocols is fast enough. On a typical request — a few kilobytes of JSON, one database query behind it — serialization is a rounding error next to the network hop and the query planner. Protobuf’s real wins are CPU at high QPS, payload size on fat objects, and a contract you can’t drift from. If your service does 40 requests a second against Postgres, none of those are your problem, and choosing gRPC will cost you browser access, curl, and HTTP caching in exchange for a benchmark you’ll never run.
So before the contenders, the three questions that actually decide it:
- Who consumes this? A browser, a third party, or another service you own? This kills more options than anything else.
- Do you want a generated contract? An IDL plus codegen buys compile-time safety across languages and costs you a build step and a schema registry.
- Does it need to be synchronous at all? This is the big one, and it’s at the bottom of the article because it’s the one that reframes the whole question.
The map
Two axes sort these eight almost perfectly: can a browser talk to it with plain fetch(), and is the contract generated from a schema file or not.
The contenders
gRPC — the internal-network default
Protobuf over HTTP/2, contract in a .proto, stubs generated for the 13 languages with official guides. Four kinds of service method — unary plus server-, client- and bidirectional-streaming — are first-class rather than bolted on. Two underrated features that don’t show up in comparison tables: deadlines propagate across hops, so a 200 ms budget set at the edge can be visible to a service four calls deep (automatic in Go and Java, opt-in in C++ and others — it only holds if every intermediate hop actually propagates), and the fixed set of 17 canonical status codes means error handling is uniform across every language in the fleet.
The costs are real and mostly operational:
- Browsers can’t speak it. They can’t control HTTP/2 frames or read trailers, so you need grpc-web — and by default a translating proxy such as Envoy, though ASP.NET Core ships gRPC-Web middleware and Connect-based servers accept it directly. This is the single most common reason gRPC gets ripped back out.
- It breaks naive load balancing. gRPC multiplexes every request over one long-lived HTTP/2 connection, so an L4/TCP load balancer pins all of a client’s traffic to whichever backend it first connected to. New pods get no traffic. You need an L7 proxy, client-side load balancing, or a service mesh — plus
MAX_CONNECTION_AGEto force periodic reconnects so the fleet rebalances. - Debugging is worse. No
curl. You needgrpcurl, and server reflection enabled to use it comfortably. - No HTTP caching. Everything is a POST over a binary framing layer; no ETags, no CDN, no
Cache-Control.
Verdict: the right default for polyglot internal service-to-service traffic at real QPS. Budget for the proxy and the load-balancing work before you commit.
REST / JSON — the one that outlives everything
Boring, universal, and quietly holds the best cards for public APIs. The killer feature isn’t the encoding, it’s that REST is the only option here that gets HTTP’s caching semantics for free: ETag, If-None-Match, Cache-Control, and a CDN that can serve your GET without ever reaching your origin. That’s an architectural capability, not a formatting choice, and every RPC protocol on this list gives it up.
It’s also the only one where your consumer’s debugging tool is already installed, your logs are readable, and a third-party integrator doesn’t need your toolchain. The weakness is the flip side of the same coin: the contract is optional, so it rots. OpenAPI helps, but only if it’s generated from the code rather than maintained beside it, and over/under-fetching is a genuine problem once mobile clients get involved.
Verdict: the default for anything public, anything cacheable, anything you don’t control both ends of.
GraphQL — a query language, not a transport
GraphQL solves a specific pain: many different clients need different shapes of the same data, and you’re tired of shipping /users/:id?include=posts,comments,avatar endpoints. The client declares the shape, the server returns exactly that, one round trip.
The bill arrives in three places. Caching is genuinely poor — a POST to a single /graphql endpoint is opaque to every HTTP cache in the path, and the workaround (automatic persisted queries plus GET, so the query hash lands in the URL) is real infrastructure you have to build. Error transport was historically unspecified — the errors array’s shape has always been in the GraphQL spec, but its HTTP status mapping wasn’t, so the classic application/json transport answers 200 OK with an errors array and every monitoring tool you own reads your failures as successes. The GraphQL-over-HTTP spec — still a draft — fixes that for the newer application/graphql-response+json media type, with 4xx/5xx when there’s no data entry and a SHOULD for a custom 294 when there’s both data and errors. The legacy behaviour is still what most servers do. And the N+1 problem is structural: a nested query fans out into per-field resolver calls, and you need DataLoader-style batching plus depth and complexity limits, or a single malicious query becomes a denial-of-service.
Verdict: worth it when client data needs genuinely vary and over-fetching is measurably hurting you. Not worth it as a default backend style — a lot of teams adopt GraphQL to solve a problem they could have solved with three more REST endpoints.
Connect-RPC — gRPC’s contract without gRPC’s browser problem
Buf’s protocol takes .proto files and codegen from gRPC and drops the HTTP/2-only requirement. A unary Connect call over HTTP/1.1 is a plain POST /package.Service/Method with a JSON body — which means curl works, browsers work with no proxy, and your existing HTTP infrastructure works. It also speaks gRPC and gRPC-Web on the wire, so a Connect server can serve gRPC clients directly. Connect additionally allows GET for side-effect-free unary methods, which quietly hands back the HTTP caching that gRPC threw away.
The trade: bidirectional streaming still needs HTTP/2 — server- and client-streaming work over HTTP/1.1 — and the ecosystem is younger and narrower. Go, TypeScript/JavaScript and Swift are stable; Kotlin and Python are beta, with a Dart implementation alongside. That’s against gRPC’s much longer tail.
Verdict: if you want protobuf contracts and a browser client, this is the answer that doesn’t require an Envoy deployment. Increasingly the right pick over gRPC-plus-grpc-web.
tRPC — types without a contract file
No IDL, no codegen, no schema. The client imports the server’s TypeScript types directly and gets end-to-end inference — rename a field on the server and the client stops compiling, instantly, with no build step in between. In a TypeScript monorepo the developer experience is genuinely the best on this list.
The constraint is the language: both ends must be TypeScript. A monorepo isn’t strictly mandatory — tRPC’s own FAQ points out you can publish your backend’s types as a private npm package and consume them from a separate frontend repo — but you give up most of the benefit doing it, and either way there is no contract artifact a Python service or a mobile team can consume. That’s not a weakness so much as a scope; tRPC is explicit that it’s for full-stack TS.
Verdict: excellent inside its box. The moment a second language appears on either end, you need something else.
SOAP — not dead, just employed
XML envelopes, WSDL contracts, and the WS-* stack: WS-Security for message-level signing and encryption, WS-AtomicTransaction for distributed transactions, WS-ReliableMessaging for guaranteed delivery. Verbose, heavy, and painful to debug — and still running the banking, telco, insurance, healthcare and government integrations that were built when those specs were the only standardised answer to “sign this individual message” and “roll back across two vendors.”
Verdict: you don’t choose SOAP; you interoperate with it. If a counterparty’s WSDL is the requirement, that’s the whole decision.
Thrift — the polyglot original
Apache Thrift predates gRPC and still beats it on raw language coverage — its README claims 28 — and on pluggability: transport and protocol are separate, swappable layers, so you can run binary or compact encoding over raw TCP without an HTTP layer at all. That last part is why it survives in latency-sensitive infrastructure.
One correction worth making to the usual comparison table: mainline Apache Thrift is request/response. Streaming is a Meta thing — fbthrift added it on top of RSocket/Rocket. If you’re on the Apache distribution, don’t plan around streaming.
Verdict: mostly a legacy or Meta-adjacent answer now. gRPC won the mindshare and the tooling; Thrift wins if you need a language gRPC doesn’t have, or raw TCP with no HTTP.
Cap’n Proto — the one that skips parsing
The pitch is structural, not incremental: the wire format is the in-memory format, so there is no parse step. You mmap the buffer and read fields directly. Protobuf, for all its speed, still decodes bytes into objects; Cap’n Proto doesn’t have that phase to optimise.
The more interesting feature is promise pipelining. Call foo(), then call bar() on its not-yet-returned result, and the whole chain ships in a single round trip — the server resolves the intermediate itself. Three dependent calls that would cost three RTTs cost one. For chatty object-graph traversal over a real network, that beats any amount of encoding speed.
The costs: narrow language support, poor debuggability, and a much smaller ecosystem. FlatBuffers occupies the same zero-copy niche with a different tradeoff set and more traction in games and mobile.
Verdict: reach for it when allocation and latency are the product — game servers, HFT, embedded, storage layers. Not a general application-API choice.
Feature comparison
| gRPC | REST/JSON | GraphQL | Connect-RPC | tRPC | SOAP | Thrift | Cap’n Proto | |
|---|---|---|---|---|---|---|---|---|
| Contract | .proto | OpenAPI (optional) | SDL schema | .proto | TS types | WSDL/XSD | .thrift | .capnp |
| Encoding | Protobuf binary | JSON | JSON | Protobuf or JSON | JSON | XML | Binary / compact | Binary, zero-copy |
| Transport | HTTP/2 only | HTTP/1.1+ | HTTP POST | HTTP/1.1 + 2 + 3 | HTTP | HTTP (SMTP is a separate Note) | TCP or HTTP | any + RPC layer |
| Browser | ✗ needs grpc-web (+ proxy by default) | ✓ native | ✓ native | ✓ native | ✓ native | ✓ native | ✗ | ✗ |
| Streaming | unary + 3 streaming modes | SSE / WS bolted on | subscriptions | server + client on H/1.1; bidi needs H/2 | subscriptions | ✗ | fbthrift only | flow-controlled (C++ only) + pipelining |
| Polyglot | 13 official | universal | good | Go/TS/Swift stable; Kotlin, Python beta | TS only | Java / .NET | 28 languages | limited |
| Bytes for the sample payload below | 22 | 55 | 55 + query text | 22 or 55 | 55 | 249 | 23 compact | 48 (29 packed) |
| Debugging | grpcurl + reflection | curl | GraphiQL | curl (JSON mode) | curl | painful | poor | poor |
| HTTP caching | ✗ none | ✓ native | poor (APQ + GET helps) | ✓ via GET on no-side-effect methods | queries are GETs — cacheable, but batching muddies the key | ✗ none | ✗ none | ✗ none |
| Error model | 17 canonical codes | HTTP status codes | 200 + errors array (draft over-HTTP spec adds 4xx/5xx) | gRPC codes → HTTP | thrown + typed | SOAP Fault | declared exceptions | protocol-level |
↔ scroll the table sideways to see every column.
The same payload, measured
“Low” and “mid–high” are the kind of adjectives that start arguments. So here is one object, encoded in every format on this page and measured:
{ "id": 12345, "name": "robert", "active": true, "score": 98.6 }
| Format | Bytes | vs JSON | gzipped | Encode | Decode |
|---|---|---|---|---|---|
| XML-RPC | 445 | 8.09× | 206 | 0.32 M/s | 0.07 M/s |
| SOAP 1.2 envelope | 249 | 4.53× | 185 | — | — |
| JSON (indented) | 72 | 1.31× | 78 | 0.28 M/s | 0.69 M/s |
| JSON (minified) | 55 | 1.00× | 72 | 0.52 M/s | 0.65 M/s |
| BSON | 54 | 0.98× | 70 | 2.52 M/s | 2.34 M/s |
| Cap’n Proto | 48 | 0.87× | 56 | 0.32 M/s | 0.71 M/s |
| MessagePack | 42 | 0.76× | 60 | 3.45 M/s | 3.81 M/s |
| CBOR | 42 | 0.76× | 60 | 1.21 M/s | 2.17 M/s |
| Thrift (binary) | 36 | 0.65× | 53 | 0.97 M/s | 1.07 M/s |
| Cap’n Proto (packed) | 29 | 0.53× | 46 | 0.31 M/s | 0.14 M/s |
| Thrift (compact) | 23 | 0.42× | 41 | 0.18 M/s | 0.32 M/s |
| Protobuf | 22 | 0.40× | 39 | 2.21 M/s | 4.99 M/s |
| Avro (schemaless) | 19 | 0.35× | 37 | 0.84 M/s | 1.12 M/s |
↔ scroll the table sideways to see every column. Sizes are exact and reproducible; throughput is CPython 3.12 on one core of a Ryzen AI Max+ 395, and measures these libraries rather than the formats — see the caveat below.
Three things fall out of that table, and two of them are the opposite of what the folklore says.
One: gzip makes a small record bigger. Look at the gzipped column — every single row is larger than its raw size. JSON goes 55 → 72, Protobuf 22 → 39, Avro 19 → 37. A gzip member costs about 18 bytes of header and trailer before it compresses anything, and there is no redundancy in 55 bytes to pay that back. If you gzip small API responses you are spending CPU to make them bigger.
Two: at batch scale, the ranking inverts. Encode a thousand of these records instead of one:
| Format | Bytes | gzipped | gzip saves |
|---|---|---|---|
| JSON (minified) | 57,238 | 8,294 | 86% |
| Avro (schemaless) | 20,026 | 8,442 | 58% |
| Thrift (compact) | 24,028 | 8,845 | 63% |
| MessagePack | 43,026 | 9,218 | 79% |
| Protobuf | 24,413 | 9,446 | 61% |
Uncompressed, Protobuf is 2.3× smaller than JSON — the number everyone quotes. Compressed, gzipped JSON is the smallest thing in the table, 12% smaller than gzipped Protobuf, which is now the largest. JSON’s repeated key names are precisely the redundancy DEFLATE was designed to eliminate; Protobuf is already dense, so there’s far less left for gzip to remove.
That should change how you read the whole comparison. If your transport compresses — and every HTTP stack does — Protobuf’s size advantage largely evaporates. What it still buys you is CPU, a schema, and generated code. Size was never the good argument for it.
Three: serialization is a rounding error. The slowest operation in the whole table — parsing XML-RPC, the worst format here in the slowest language on the list — still runs 70,000 times a second. The fastest decoder does five million. That is 0.2 to 14 microseconds per call, and a single 5 ms database query costs between 350× and 25,000× more than any of it. Unless you are fanning out tens of thousands of internal calls per second, you are choosing a wire format for its contract, its tooling and its ecosystem — not its speed.
The throughput caveat, stated plainly: these numbers rank library implementations, not formats. Protobuf's Python binding is backed by C++, MessagePack's is C, and thriftpy2's compact protocol is pure Python — which is why Thrift compact looks slow here and would not in Go or Rust. Cap'n Proto's decode number is the least meaningful of all: its whole premise is that you don't decode, you read fields in place, and this benchmark forces a field access through the Python binding. Read the order of magnitude, not the ranking.
Streaming, push and backpressure
The other axis worth putting numbers-adjacent structure on: what each option does when data doesn’t arrive as one request and one response.
| Streaming | Push to a browser | Backpressure | Survives the peer being down | |
|---|---|---|---|---|
| gRPC | all four method kinds | server-streaming only, via grpc-web | HTTP/2 flow control | ✗ |
| REST | none natively | SSE or WebSocket, bolted alongside | TCP window only | ✗ |
| GraphQL | subscriptions | WebSocket or SSE | none in the protocol | ✗ |
| Connect | all four; bidi needs HTTP/2 | server-streaming, no proxy | HTTP/2 flow control | ✗ |
| tRPC | subscriptions | WebSocket or SSE | none in the protocol | ✗ |
| Cap’n Proto | flow-controlled streams (C++) | n/a | explicit flow control | ✗ |
| RSocket | yes | yes | Reactive Streams credits | ✗ |
| Kafka · NATS · RabbitMQ | it’s a log, not a call | via a gateway | consumer-paced by design | ✓ yes |
↔ scroll the table sideways to see every column.
Only one row in that last column says yes, and it isn’t an RPC framework — which is the same conclusion the async section reaches from the other direction.
Decision shortcuts
| Situation | Pick |
|---|---|
| Internal microservices, polyglot, high QPS | gRPC |
| Public API, third-party consumers | REST |
| Mobile/web client with varied data needs, over-fetching pain | GraphQL |
Want gRPC contracts and browser / curl access | Connect-RPC |
| Full-stack TypeScript monorepo | tRPC |
| Enterprise / legacy, WS-* requirements | SOAP |
| A language gRPC doesn’t support, or raw TCP with no HTTP | Thrift |
| Extreme latency / allocation sensitivity | Cap’n Proto / FlatBuffers |
| Async, decoupled, fan-out, replay | Kafka / NATS / RabbitMQ — not RPC at all |
What the table can’t hold
Three things decide more real projects than any row above.
Caching is an architecture, not a feature. REST is the only entry that inherits HTTP’s cache semantics without effort — and a CDN serving 80% of your GETs is worth more than every serialization benchmark on this page combined. Connect earns some of it back with GET on methods declared NO_SIDE_EFFECTS. GraphQL needs persisted queries to get any of it. gRPC, Thrift and Cap’n Proto have none. If your read traffic dwarfs your writes, this row alone can pick the winner.
Schema evolution is where contracts pay off. Protobuf’s field numbers are the good design here: fields are identified by tag, not name, unknown fields survive a round trip through an old service, and reserved stops someone reusing a retired number and silently reinterpreting old data. GraphQL takes the opposite philosophy — no versioning at all, add fields freely, mark old ones @deprecated, and let usage analytics tell you when it’s safe to remove them. REST has no answer beyond convention: /v2/, a header, or a lot of discipline. Whichever you pick, additive change must be free, or your services can’t deploy independently and you’ve built a distributed monolith.
Error semantics leak everywhere. gRPC’s fixed status codes mean a retry policy written once works fleet-wide, and UNAVAILABLE vs FAILED_PRECONDITION tells a client whether retrying is even meaningful. GraphQL’s 200-with-errors is the opposite: your load balancer, your APM and your alerting all see success. In fairness, gRPC does the same thing one layer down — a gRPC error is also an HTTP 200, with the real status in the trailers — but every gRPC-aware proxy and client library reads grpc-status, while nothing generic reads a GraphQL errors array. If you run GraphQL, fixing your observability to read it is not optional work.
The real axis: synchronous vs asynchronous
Everything above is request/response. Every one of these protocols shares a property that no amount of protocol tuning fixes: if the callee is down, the caller fails. Retries and circuit breakers manage the symptom; the coupling is structural. A synchronous call chain of five services has the availability of the product of all five.
If your services need to survive each other being down, no RPC framework gets you there. You want a broker or an event log — and if you’re already doing CQRS or event sourcing, you own that infrastructure, so the question “gRPC or REST?” often turns out to be scoped to the thin synchronous edge of a system that’s mostly messages anyway.
It isn’t free. Async buys decoupling and replay and pays in eventual consistency, idempotent consumers, out-of-order handling, and the fact that you can’t tell the user “done” — only “accepted.” Most real systems end up with both: RPC where a human is waiting for an answer, messages where they aren’t.
The Rails footnote
One practical note, because it catches people: gRPC’s Ruby story is the weakest of the major languages, and the reason is forking. The grpc gem is a C extension around grpc-core whose own source spells the problem out: fork support is Linux-only and opt-in behind GRPC_ENABLE_FORK_SUPPORT=1, servers and bidirectional streams manage background threads and are not fork-safe, and GRPC.prefork has to be called from the same thread that first initialised gRPC — because the library lazy-initialises when you create your first gRPC object. That is precisely the dance a clustered Puma gives you no clean hook for, which is why the Puma issue asking for one is still open.
None of that is fatal, but it is real operational cost paid every deploy. If Rails is on either end of the wire, REST or Connect-RPC will cost you far less than the wire-format savings are worth — and Connect is a particularly good fit, because a unary Connect call is just a POST with a JSON body, which a plain Rails controller can serve without any of the gem’s machinery.
TL;DR
TL;DR: Decide the consumer first, the encoding last. Browser or third party on the other end → REST (and you get HTTP caching,
curl, and CDNs free — the thing every RPC option gives up). Internal polyglot services at real QPS → gRPC, but budget for the grpc-web proxy, the L7 load balancing, and the loss of caching. Want protobuf contracts and browser access → Connect-RPC; it’s gRPC’s contract without gRPC’s HTTP/2-only browser problem, and unary calls arecurl-able. Varied client data shapes → GraphQL, but you own persisted queries for caching, DataLoader for N+1, depth limits for DoS, and fixing your monitoring to read theerrorsarray behind a200(the draft over-HTTP spec adds real status codes; most servers don’t use it yet). Full-stack TypeScript monorepo → tRPC, until a second language shows up. SOAP you interoperate with, you don’t choose. Thrift for a language gRPC lacks or raw TCP; Cap’n Proto/FlatBuffers when allocation is the product.On the numbers: for a four-field object, Protobuf is 22 bytes against minified JSON’s 55 and SOAP’s 249 — but two measured results undercut the usual argument. gzip makes a single small record bigger (JSON 55 → 72, Protobuf 22 → 39; a gzip member costs ~18 bytes before it compresses anything), and at a thousand records, gzipped JSON is smaller than gzipped Protobuf — 8,294 vs 9,446 bytes, because repeated key names are exactly what DEFLATE eats. Compression is where Protobuf’s size advantage goes to die; keep it for the CPU, the schema and the codegen. And every encoder here runs between 0.2 and 14 microseconds, which a single database query outweighs by 350× or more.
Three things outrank the whole comparison table: caching is architecture, not formatting; schema evolution must make additive change free or your services can’t deploy independently; and synchronous vs asynchronous is the axis that actually matters — if your services must survive each other being down, no RPC framework fixes that, you want a broker or an event log. And if Rails is on either end, skip gRPC: the Ruby C-extension and forking-server friction cost more than the bytes you save.
Sources
The byte counts and throughput figures above were measured for this article, not quoted: the payload was encoded with protobuf 7.35.1, msgpack 1.2.1, cbor2 6.1.4, fastavro 1.12.2, thriftpy2 0.7.0, pycapnp and pymongo’s BSON on CPython 3.12.13, one core, best of five runs of 20,000 iterations each. The Protobuf and Avro encodings were also checked by hand against their wire specs (3 + 8 + 2 + 9 = 22 bytes; 3 + 7 + 1 + 8 = 19).
- gRPC — official documentation · supported languages
- Apache Thrift — 28 supported languages
- gRPC status codes and their use
- gRPC load balancing — why L4 isn’t enough
- gRPC-Web — protocol and proxy requirement
- Protocol Buffers — schema evolution rules
- Connect — a better gRPC (Buf)
- Connect protocol reference — HTTP GET for idempotent methods
- GraphQL over HTTP — specification (status codes, media types)
- GraphQL — performance: GET requests, persisted queries, the N+1 problem
- GraphQL — security: depth, breadth and complexity limiting
- tRPC — end-to-end typesafe APIs
- Apache Thrift — documentation and language bindings
- Cap’n Proto — promise pipelining and the RPC protocol
- FlatBuffers — zero-copy serialization
- SOAP 1.2 — W3C Recommendation (the WS-* stack below is OASIS, not W3C)
- WS-Security · WS-AtomicTransaction 1.2 · WS-ReliableMessaging 1.2 — OASIS
- HTTP semantics (ETag, If-None-Match) — RFC 9110
- HTTP caching (Cache-Control, Age, Expires) — RFC 9111