gRPC vs REST: What the Benchmarks Don't Tell You
I spent the better part of a month porting one real service from REST/JSON to gRPC, and the result surprised me. Not because gRPC was slow — it wasn't — but because the numbers I kept seeing in benchmark posts turned out to measure the wrong thing.
This is the honest before/after: what got faster, what got worse, and how I'd decide differently next time.
The service I moved
A small internal API that serves paginated lists of objects with nested fields. A typical response was 80–200 KB of JSON, built by a Python backend, consumed by a web app and two mobile clients. Nothing exotic. Traffic was moderate, but the list endpoints were chatty — every screen load fired three or four requests, and on mobile networks the payload size was starting to hurt.
The plan: define a .proto, generate clients, and swap the transport while keeping the API surface semantically identical.
What the change actually looked like
The contract-first part is genuinely nice. One file, one source of truth:
syntax = "proto3";
package inventory.v1;
service ItemService {
rpc ListItems(ListItemsRequest) returns (ListItemsResponse);
rpc StreamItems(ListItemsRequest) returns (stream Item); // later
}
message ListItemsRequest {
string cursor = 1;
int32 page_size = 2;
}
message Item {
string id = 1;
string name = 2;
map<string, string> attributes = 3;
int64 updated_at = 4;
}
The Python server side:
import grpc
from concurrent import futures
import inventory_pb2, inventory_pb2_grpc
class ItemService(inventory_pb2_grpc.ItemServiceServicer):
def ListItems(self, request, context):
items = query_items(cursor=request.cursor, limit=request.page_size)
return inventory_pb2.ListItemsResponse(
items=[to_proto(i) for i in items],
next_cursor=items.next_cursor or "",
)
server = grpc.server(futures.ThreadPoolExecutor(max_workers=16))
inventory_pb2_grpc.add_ItemServiceServicer_to_server(ItemService(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()
The client call is roughly as compact as the old requests.get version:
import grpc
import inventory_pb2, inventory_pb2_grpc
with grpc.insecure_channel("service.internal:50051") as channel:
stub = inventory_pb2_grpc.ItemServiceStub(channel)
resp = stub.ListItems(inventory_pb2.ListItemsRequest(page_size=50))
for item in resp.items:
print(item.name, item.updated_at)
Nothing about this part was painful. The code generation, the typed messages, the IDE autocomplete on response fields — all of it felt like an upgrade over hand-rolled dictionaries and response.json()["data"]["items"][0]["id"].
What actually got faster
Measured on the same box, same database, realistic payloads:
- Payload size: about 65% smaller. Protobuf's binary encoding wins hard on repetitive nested structures. This was the win I was actually chasing, and it delivered.
-
Serialization cost: lower. Python's
jsonis not slow, butprotobufC-accelerated encoding beat it by a wide margin on the 200 KB responses. - End-to-end latency (p95): about 12% better. That's it. Not 3x, not 10x. Because for a single request over the public internet, the dominant cost is network round-trip and the server's actual work — not the wire format.
The unsung hero was HTTP/2 multiplexing. When a client fires several requests over one connection, they no longer queue behind each other. On mobile that collapsed three sequential round trips into one connection's worth of overlap. That showed up more in "the app feels snappier" than in any single-request benchmark.
Where it hurt — the parts benchmarks skip
Here's the stuff no latency chart warns you about:
1. Debugging gets worse. curl is useless against a gRPC endpoint. You need grpcurl with server reflection enabled, or a GUI client, or carefully crafted test scripts. Every incident now requires one more tool in the chain. When a partner reported "the API is broken," my first step went from one command to three.
2. Browsers can't call it. No browser speaks gRPC natively. Web clients need grpc-web or a translation proxy (Envoy, grpc-gateway). Your frontend devs cannot just open the network tab and hit the endpoint — which means the "REST-like" surface they see is now a generated artifact, and the error messages they read are whatever the proxy chose to emit.
3. My CDN caching silently died. Read-heavy list endpoints used to sit behind an edge cache keyed on URL + query string. gRPC uses POST semantics over a binary body — edge caching of those responses is awkward or impossible. I had to rebuild caching at the application layer. Nobody mentions the caching regression in a benchmark post.
4. Schema discipline becomes a process. Protobuf field numbers must never be reused, and deprecated fields linger for compatibility. That's correct and good — but it means a code review now includes "is this a new field number or a recycled one?" JSON tolerated sloppy additions forever; proto punishes them loudly and immediately.
5. Infra setup has edges. The load balancer had to speak HTTP/2 (h2c or TLS with ALPN). Health checks, timeouts, and retry policies all had to be reconfigured for a binary protocol that doesn't answer a plain GET /healthz the way you'd expect. Adding grpc_health_v1 was easy; remembering to do it was the real cost.
Where gRPC genuinely shines
With all that said, I'd still choose gRPC again — for the right service:
- Streaming. Server-streaming is dramatically better than my old pattern of paginated polling for large exports. Built-in backpressure and cancellation beat anything I was hand-rolling with SSE.
- Contract-first teams. When five microservices in three languages share a boundary, generated clients eliminate a whole class of "the field name changed in the docs again" bugs.
- Internal traffic. When both ends are yours, on your network, with your load balancer — the debugging and browser objections mostly disappear.
-
Typed errors.
google.rpc.Statuswith rich detail is a real upgrade over guessing which HTTP code maps to which internal failure.
The decision guide I wish I'd had
Choose gRPC when:
- Both ends are services you control (internal microservices, service mesh, B2B server-to-server)
- You need streaming or very high throughput
- Payload size on constrained networks is a measured pain point
- You have several language teams that will benefit from codegen
Choose REST + JSON when:
- Browsers and third parties are the primary consumers
- CDN/edge caching is doing real work for you
- Debuggability and simplicity beat wire efficiency
- You're a small team and don't want to own the gateway/proxy stack
The middle path is popular for good reason: keep gRPC between your internal services, and expose REST/OpenAPI at the public edge through a gateway that translates. You pay for the proxy, but you keep curl-able, cache-able public endpoints.
Bottom line
Benchmarks compare bytes. Production compares developer days. My honest numbers: payload −65%, p95 latency −12%, first-week debugging friction up noticeably, CDN caching gone for those endpoints. gRPC is not "faster REST" — it's a different tool with different sharp edges.
Next time I'm choosing a transport, the question I ask first isn't "which is faster?" It's "who consumes this API, and where does the latency actually live?" That question would have saved me a month.
Building and running APIs for a living. Writing about what I learn along the way.
Top comments (0)