13 min read

gRPC vs REST for Microservices: Performance Compared

gRPCRESTMicroservicesAPI

Every microservices team eventually faces this decision: gRPC or REST for internal service-to-service communication? The internet has strong opinions. Most of them are based on benchmarks that do not reflect real workloads or DX arguments that ignore operational reality.

Here is a practical comparison from running both in production.

System Architecture

In most real setups, both protocols live behind one gateway. External clients speak REST over JSON. Internal services speak gRPC over HTTP/2. The gateway handles auth, rate limiting, and routing, and it can transcode gRPC responses to JSON when you need to debug something quickly.

The schema registry is the source of truth for both. Proto files live in git (linted with something like buf), OpenAPI specs sit next to them, and generated clients flow out of the registry. When the registry is the contract, breaking changes get caught in CI instead of at 3 AM.

+----------------+                    +------------------+
|  Browser / CLI |---REST / JSON----->|  API Gateway     |
+----------------+                    |  (auth, routing, |
+----------------+                    |   rate limits,   |
|  Mobile App    |---REST / JSON----->|   transcoding)   |
+----------------+                    +--------+---------+
                                               |
+-----------+      +---------------+           v
|  IoT /    |      | Schema        |  +------------------+
|  Service  +----->| Registry      +->|  gRPC Service    |
|  Clients  | gRPC | (proto repo,  |  |  (HTTP/2 binary) |
+-----------+      |  OpenAPI)     |  +------------------+
                   +---------------+           |
                            |                 v
                            +-------->+------------------+
                                     |  REST Service    |
                                     |  (HTTP/1.1 JSON) |
                                     +------------------+

The Performance Story

gRPC is faster than REST. This is well-established and not really debatable for the core case. Protocol Buffers are a binary format, smaller on the wire than JSON, and faster to serialize and deserialize. HTTP/2 multiplexing eliminates head-of-line blocking.

But how much faster, and does it matter?

In typical internal microservice workloads (small to medium payloads, sub-millisecond to low-millisecond processing), gRPC tends to be 2x to 5x faster in serialization overhead and 20 to 30 percent smaller on the wire. For a service doing 10,000 requests per second with 1KB payloads, that translates to meaningful CPU savings on serialization.

However, serialization is rarely the bottleneck. Database queries, downstream calls, and business logic dominate request latency. If your p99 is 200ms because of a slow query, switching from REST to gRPC saves you 2ms. That is real but not transformative.

When gRPC performance matters:

When it does not matter enough to decide on:

Streaming Patterns

gRPC has four RPC patterns, and this is the one area where REST has no real answer.

Unary. One request, one response. This is the RPC equivalent of a normal REST call and covers the vast majority of service-to-service traffic.

Server streaming. The client sends one request and the server streams back a sequence of responses. Good for live logs, stock tickers, progress updates on long jobs, and tailing event feeds. REST can approximate this with server-sent events, but that is a separate mechanism with its own client code, not part of your API contract.

Client streaming. The client streams a sequence of requests and the server responds once at the end. Good for telemetry upload, IoT sensor batching, and large file ingestion where you want to chunk the payload without holding one giant request in memory.

Bidirectional streaming. Both sides send independently. This is chat, live collaboration, and real-time control loops (think drone or robot command channels). Implementing this over REST means bolting on WebSockets, which is a different protocol with different auth, different load balancer behavior, and different debugging tools.

The operational catch with streaming: streams hold connections open. That complicates load balancing (long-lived connections do not rebalance on their own), timeouts (idle streams need keepalive configuration, not just request timeouts), and capacity planning (a thousand open streams is cheap, a million is not). If you adopt streaming, set explicit keepalive and max-connection-age policies on day one, or you will discover the defaults during an incident.

Schema Evolution and Contracts

This is where gRPC has a genuine structural advantage. Protocol Buffers enforce a schema. Fields have numbers, types are explicit, and backward compatibility rules are well-defined (never reuse field numbers, only add optional fields).

syntax = "proto3";

message GetUserRequest {
  string user_id = 1;
}

message GetUserResponse {
  string user_id = 1;
  string name = 2;
  string email = 3;
  // Added in v2, safe for old clients to ignore
  string phone = 4;
}

With REST and JSON, the schema is implicit. You document it in OpenAPI if you are disciplined, but nothing enforces it at compile time. A developer renames a JSON field, the producer deploys, and the consumer breaks at runtime.

That said, OpenAPI with code generation narrows the gap significantly. If your team generates clients from OpenAPI specs and runs contract tests, REST can be nearly as safe as gRPC. The difference is that gRPC gives you this by default while REST requires discipline.

Proto schema evolution rules that actually matter. Field numbers are the contract, not field names. Never reuse a field number, even after deleting the field: reserve the number and the name so nobody reuses them by accident.

message GetUserResponse {
  string user_id = 1;
  string name = 2;
  reserved 3;           // email was removed, number is retired
  reserved "email";     // name is retired too
  string phone = 4;
}

Only add optional fields. Never change a field's type (an int32 to int64 is a wire-format change and will corrupt data). Never renumber. The classic failure: a team deletes field 3, another team later adds a new field as number 3, old clients decode the new field as the old one, and you get silent data corruption that no test catches because both sides "work".

On the REST side, the equivalent discipline is: treat unknown fields as ignorable. Most JSON parsers drop unknown keys by default, but strict decoders fail on them, so agree on the policy per client and write it down. And run buf breaking (for protos) or oasdiff breaking (for OpenAPI) in CI on every PR. Schema evolution is a process, not a hope.

Developer Experience

This is where REST often wins, and it matters more than people admit.

Debugging. You can curl a REST endpoint. You can paste a URL into a browser. You can read the request and response in plain text. With gRPC, you need grpcurl or a GUI client, and binary payloads are opaque without decoding.

Learning curve. Every developer knows HTTP and JSON. gRPC requires learning Protocol Buffers, the gRPC toolchain, code generation, and HTTP/2 concepts. For a team new to microservices, this is real overhead.

Browser and external clients. If a service might ever be called from a browser or a third-party integration, REST (or gRPC-Web, which adds complexity) is the answer. gRPC's HTTP/2 requirement is a problem for many corporate proxies and load balancers.

Code generation friction. gRPC requires a codegen step. You define protos, run protoc, check in or generate the code. This is fine once set up, but it adds a build step that REST with JSON does not need. Version mismatches between generated code and proto definitions cause subtle bugs.

Error models. REST error handling is familiar: HTTP status codes, a JSON error body, and every tool on earth understands it. gRPC has its own 17 status codes (NOT_FOUND, INVALID_ARGUMENT, UNAVAILABLE, and so on) plus a details payload for structured error info. The gRPC codes are consistent across languages, which is nice, but they are coarser than HTTP codes: there is no direct equivalent of 429 (rate limited) or 409 (conflict), so teams end up encoding those in details or metadata. When you expose gRPC through a gateway, the HTTP-to-gRPC status mapping loses nuance. Pick one error model per surface and document the mapping explicitly.

Debugging in practice. curl works on any REST endpoint from any machine. grpcurl is the gRPC equivalent, and it works well once you have it, but it is one more tool to install, and you need the proto definitions or server reflection enabled to use it against a new service. Server reflection is worth enabling in non-production environments; in production, weigh it against the information it exposes. Either way, keep a corpus of sample requests in your runbooks. The on-call engineer should be able to hit any RPC without reading a wiki.

gRPC-Web and the browser problem. Browsers cannot speak native gRPC: the fetch and XHR APIs do not expose HTTP/2 trailers, which gRPC needs. gRPC-Web works around this with a proxy (usually Envoy) that translates between gRPC-Web and gRPC, and it only supports unary and server-streaming calls. So "just use gRPC-Web" really means adding a translation proxy to your architecture and accepting reduced streaming support. If browsers are in the picture at all, start with REST at the edge and keep gRPC internal.

The Decision Framework

Here is how I think about it:

Choose gRPC when:

Choose REST when:

Consider both (hybrid):

Scalability

What changes at 10x and 100x load is not the protocol in the abstract, it is the infrastructure around it.

Connections. HTTP/2 multiplexes many streams over one TCP connection, so gRPC services hold far fewer connections than equivalent HTTP/1.1 services. At 10x, that means fewer TLS handshakes, less connection-churn CPU, and smaller connection tables on your load balancers. But the flip side bites at scale: gRPC connections are long-lived, and a naive round-robin L4 load balancer will pin each connection to one backend. When a backend gets slow or a new pod comes up, traffic does not rebalance, because there are no new connections to distribute. At 100x, this imbalance is the first thing that breaks. The fix is a connection-aware data plane: Envoy with least-request load balancing, or client-side balancing via xDS, or at minimum MaxConnectionAge so connections recycle and rebalance periodically.

Head-of-line blocking. HTTP/2 solved HTTP-level head-of-line blocking, but all streams still share one TCP connection, so a single lost packet stalls every stream on that connection. At high throughput this shows up as correlated latency spikes that are hard to attribute. HTTP/3 (QUIC) fixes this properly with independent streams, but production adoption is still early. Know which layer your blocking lives at before blaming the protocol.

Payload size. Protobuf's size advantage compounds at 100x: 20 to 30 percent smaller payloads means 20 to 30 percent less bandwidth, less memory in buffers, and faster parsing under load. Also set explicit message size limits (grpc.MaxRecvMsgSize, typically 4MB by default in grpc-go). Without a limit, one malformed or malicious large message can OOM a pod.

Horizontal scaling. gRPC services scale horizontally like any stateless service, with one extra rule: keep connection counts even across backends. Watch per-backend connection counts in your dashboards, not just request rates. If one pod has 10x the connections of the others, your load balancer config is wrong, and no amount of autoscaling will fix it.

Security Considerations

gRPC: mTLS between services. The standard pattern is mutual TLS so every service proves its identity to every other service. In practice, teams do this through a service mesh (Istio, Linkerd) so the application code never touches certificates. Per-RPC authorization then happens in interceptors: a token or identity in the request metadata, checked against a policy. This is clean, but it means your security posture depends on the mesh control plane being healthy.

REST: JWT or OAuth at the gateway. Terminate TLS at the gateway or load balancer, validate the JWT signature at the edge, and pass the claims downstream. The gateway is also where rate limiting lives. The risk is the classic one: once traffic is inside the perimeter, services trust each other too much. Even for internal REST, require service-to-service auth on anything sensitive.

TLS termination points. For REST, terminating at the L7 gateway is normal and fine. For gRPC, terminating TLS at the gateway breaks end-to-end mTLS, because the gateway becomes a man in the middle by design. Options: pass TCP through to the service and terminate there, or terminate at the gateway and re-encrypt to the backend. Pick one deliberately and document it; mixed termination across services is how you end up with plaintext hops nobody remembers.

Attack vectors. Oversized messages (set receive limits), malformed payloads that crash generated parsers (fuzz your proto parsing in CI if you accept untrusted input), and deprecated endpoints with weaker validation that are still reachable. Defense in depth means validating at the gateway and in the service, rate limiting per RPC method or endpoint, and treating internal traffic as untrusted by default.

Production Checklist

What I Have Seen Work

In practice, most teams I have worked with end up with REST for the majority of services and gRPC for specific high-throughput or streaming use cases. The teams that go all-in on gRPC usually have strong platform teams that manage the toolchain and codegen infrastructure.

The worst outcome is a team that picks gRPC for the performance story, then struggles with debugging, codegen issues, and proxy problems, while their actual bottleneck is a slow database query that no protocol choice would fix.

Start with the operational reality, not the benchmark. Measure your actual serialization overhead before optimizing it. And remember: the best protocol is the one your team can debug at 3 AM without a wiki page.

More articles