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:

  1. Who consumes this? A browser, a third party, or another service you own? This kills more options than anything else.
  2. 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.
  3. 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.

API protocols mapped by browser reachability and contract strength browser-native — plain fetch(), curl, CDNs needs a proxy — service-to-service generated from an IDL contract optional or single-language Connect-RPC .proto, JSON or binary SOAP WSDL, XML envelope GraphQL SDL, JSON gRPC .proto, HTTP/2 only Cap'n Proto .capnp, zero-copy Thrift .thrift, 28 languages tRPC TS types, no codegen REST / JSON OpenAPI, if you bother this quadrant stays empty — if you're paying the binary-protocol tax, you take the schema too Connect-RPC sits top-left on purpose: it's the deliberate attempt to keep gRPC's contract and lose gRPC's browser problem.
The horizontal split does most of the work. If a browser or a third party is on the other end, the right half is off the table before you compare a single benchmark.

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:

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

gRPCREST/JSONGraphQLConnect-RPCtRPCSOAPThriftCap’n Proto
Contract.protoOpenAPI (optional)SDL schema.protoTS typesWSDL/XSD.thrift.capnp
EncodingProtobuf binaryJSONJSONProtobuf or JSONJSONXMLBinary / compactBinary, zero-copy
TransportHTTP/2 onlyHTTP/1.1+HTTP POSTHTTP/1.1 + 2 + 3HTTPHTTP (SMTP is a separate Note)TCP or HTTPany + RPC layer
Browser needs grpc-web (+ proxy by default) native native native native native
Streamingunary + 3 streaming modesSSE / WS bolted onsubscriptionsserver + client on H/1.1; bidi needs H/2subscriptionsfbthrift onlyflow-controlled (C++ only) + pipelining
Polyglot13 officialuniversalgoodGo/TS/Swift stable; Kotlin, Python betaTS onlyJava / .NET28 languageslimited
Bytes for the sample payload below225555 + query text22 or 555524923 compact48 (29 packed)
Debugginggrpcurl + reflectioncurlGraphiQLcurl (JSON mode)curlpainfulpoorpoor
HTTP caching none nativepoor (APQ + GET helps) via GET on no-side-effect methodsqueries are GETs — cacheable, but batching muddies the key none none none
Error model17 canonical codesHTTP status codes200 + errors array (draft over-HTTP spec adds 4xx/5xx)gRPC codes → HTTPthrown + typedSOAP Faultdeclared exceptionsprotocol-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 }
Bytes on the wire for the same four-field object, by encoding Bytes on the wire — one record, four fields XML-RPCSOAP 1.2JSON (indented) JSON (minified)Cap'n ProtoMessagePack · CBOR Cap'n Proto (packed)Thrift (compact)ProtobufAvro 445 249 72 55 48 42 29 23 22 19 23× between the smallest and largest — and almost the entire spread is XML's angle brackets, not clever binary packing.
The same four fields. Avro is smallest because it carries no field tags at all — the schema supplies the order — while Cap'n Proto pays for its zero-copy layout in fixed-width, 8-byte-aligned slots.
FormatBytesvs JSONgzippedEncodeDecode
XML-RPC4458.09×2060.32 M/s0.07 M/s
SOAP 1.2 envelope2494.53×185
JSON (indented)721.31×780.28 M/s0.69 M/s
JSON (minified)551.00×720.52 M/s0.65 M/s
BSON540.98×702.52 M/s2.34 M/s
Cap’n Proto480.87×560.32 M/s0.71 M/s
MessagePack420.76×603.45 M/s3.81 M/s
CBOR420.76×601.21 M/s2.17 M/s
Thrift (binary)360.65×530.97 M/s1.07 M/s
Cap’n Proto (packed)290.53×460.31 M/s0.14 M/s
Thrift (compact)230.42×410.18 M/s0.32 M/s
Protobuf220.40×392.21 M/s4.99 M/s
Avro (schemaless)190.35×370.84 M/s1.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:

FormatBytesgzippedgzip saves
JSON (minified)57,2388,29486%
Avro (schemaless)20,0268,44258%
Thrift (compact)24,0288,84563%
MessagePack43,0269,21879%
Protobuf24,4139,44661%

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.

StreamingPush to a browserBackpressureSurvives the peer being down
gRPCall four method kindsserver-streaming only, via grpc-webHTTP/2 flow control
RESTnone nativelySSE or WebSocket, bolted alongsideTCP window only
GraphQLsubscriptionsWebSocket or SSEnone in the protocol
Connectall four; bidi needs HTTP/2server-streaming, no proxyHTTP/2 flow control
tRPCsubscriptionsWebSocket or SSEnone in the protocol
Cap’n Protoflow-controlled streams (C++)n/aexplicit flow control
RSocketyesyesReactive Streams credits
Kafka · NATS · RabbitMQit’s a log, not a callvia a gatewayconsumer-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

SituationPick
Internal microservices, polyglot, high QPSgRPC
Public API, third-party consumersREST
Mobile/web client with varied data needs, over-fetching painGraphQL
Want gRPC contracts and browser / curl accessConnect-RPC
Full-stack TypeScript monorepotRPC
Enterprise / legacy, WS-* requirementsSOAP
A language gRPC doesn’t support, or raw TCP with no HTTPThrift
Extreme latency / allocation sensitivityCap’n Proto / FlatBuffers
Async, decoupled, fan-out, replayKafka / 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.

Synchronous RPC fails when the callee is down; a broker or log absorbs the outage Synchronous RPC — gRPC, REST, GraphQL, all of them Service A call · blocks Service B DOWN → A fails too. The request is gone; the caller owns the retry and the timeout. Asynchronous messaging — Kafka, NATS, RabbitMQ Service A publish log / queue consume Service B DOWN → A is fine. Messages wait. B replays from its offset. No RPC framework fixes the top row. Choosing gRPC over REST changes the encoding; it does not change the coupling. The cost of the bottom row: eventual consistency, idempotent consumers, out-of-order delivery, and no answer to give the caller right now.
Synchronous vs asynchronous is a bigger decision than gRPC vs REST — it changes what happens when a dependency dies, not just how the bytes are laid out.

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 are curl-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 the errors array behind a 200 (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).

api design