← Back to Blog

Why gRPC? When REST API Is Not Enough for Modern Systems

grpcmicroservicesapi-designperformance

Why gRPC? When REST API Is Not Enough for Modern Systems

April 29, 2026
4,470 views
5.0
Paál Gyula
Paál Gyula
Founder & Lead Architect

Understanding when and why to choose gRPC over REST APIs for microservices, covering performance benefits and use cases where gRPC excels.


🚀 Introduction

Most "gRPC vs REST" articles end with "it depends". True, but useless. The honest version is narrower: gRPC pays for itself when services talk to each other a lot, and it costs you when humans or browsers are on the other end.

gRPC is a Remote Procedure Call framework built on three things: HTTP/2 as transport, Protocol Buffers as the schema and wire format, and code generation as the developer experience. You don't hand-write clients, you don't guess field names, and you don't discover a renamed JSON key in production.

This post covers what actually changes when you adopt it — including the operational bills that rarely make it into the benchmarks.

📋 Key Topics Covered

  • gRPC vs REST: what the performance difference really comes from
  • Protocol Buffers and safe schema evolution
  • The four call types: unary, server streaming, client streaming, bidirectional
  • When gRPC makes sense (internal services, low latency, streaming)
  • When REST is still the right choice (public APIs, browser clients)
  • gRPC-Web and Connect for browser compatibility
  • Tooling, code generation, and buf
  • Operational realities: load balancing, deadlines, retries, observability
  • A realistic migration path

🧩 What gRPC Actually Is

The name says "RPC", but the interesting part is the contract. You write a .proto file, and both sides are generated from it.

protobuf
1syntax = "proto3";
2
3package orders.v1;
4
5option go_package = "github.com/pilab/orders/gen/orders/v1;ordersv1";
6
7service OrderService {
8  rpc GetOrder(GetOrderRequest) returns (Order);
9  rpc WatchOrders(WatchOrdersRequest) returns (stream OrderEvent);
10}
11
12message GetOrderRequest {
13  string order_id = 1;
14}
15
16message Order {
17  string id = 1;
18  string customer_id = 2;
19  int64 total_cents = 3;
20  OrderStatus status = 4;
21}
22
23enum OrderStatus {
24  ORDER_STATUS_UNSPECIFIED = 0;
25  ORDER_STATUS_PENDING = 1;
26  ORDER_STATUS_PAID = 2;
27  ORDER_STATUS_CANCELLED = 3;
28}

That single file produces the server interface, the client stub, the types, and the serialization — in every language you generate for.

⚡ Performance: What's Real and What's Marketing

You'll see "gRPC is 7–10× faster than REST" headlines. That number comes from synthetic benchmarks with tiny payloads and no network in the way. Here's where the difference actually comes from:

Source of gainWhat it doesReal-world impact
Binary encodingProtobuf skips field names, uses varints and tag numbers30–60% smaller payloads than equivalent JSON
No JSON parsingDecoding is a field-tag walk, not a tokenizerLarge on hot paths, invisible on 10 req/s endpoints
HTTP/2 multiplexingMany in-flight calls over one connection, no head-of-line queueBig when a service fans out to dozens of peers
Persistent connectionsNo TCP+TLS handshake per callSaves 1–2 RTT per call vs naive HTTP/1.1 clients
HPACK header compressionRepeated metadata sent onceMatters for chatty small-payload traffic

The honest summary: on internal service-to-service traffic with high call volume, expect meaningful improvements in p99 latency and CPU spent on serialization. On a CRUD endpoint called twice a minute, expect nothing.

📜 Protocol Buffers and Schema Evolution

Protobuf's compatibility rules are what make gRPC safe in a system where you can't deploy everything at once.

The rules that keep you compatible:

  1. Never reuse a field number. The number is the wire identity; the name is only for humans and generated code.
  2. Reserve what you remove. reserved 4; and reserved "old_field"; make the compiler stop you from making the previous mistake.
  3. Add new fields as optional with new numbers. Old readers skip unknown fields instead of failing.
  4. Never change a field's type. Changing int32 to string silently corrupts decoding.
  5. Always define enum value 0 as UNSPECIFIED. Proto3 has no "field was absent" for scalars — zero is the default, so make the default mean "not set".
  6. Version the package, not the endpoint. orders.v1orders.v2 when you truly break; add fields inside v1 otherwise.
protobuf
1message Order {
2  string id = 1;
3  string customer_id = 2;
4  int64 total_cents = 3;
5  OrderStatus status = 4;
6
7  reserved 5;                 // was: legacy_price_float
8  reserved "legacy_price_float";
9
10  string currency = 6;        // added later, old clients simply ignore it
11}

Compare this to REST: nothing stops someone from renaming total_cents to totalAmount in a JSON response. The consumer finds out at runtime.

🔀 The Four Call Types

This is genuinely something REST cannot express without bolting on WebSockets or SSE.

Where each one earns its keep:

  • Unary — the default. Anything you'd have written as a GET or POST.
  • Server streaming — live order feeds, log tailing, progress on a long job, paginated exports without pagination tokens.
  • Client streaming — metric and telemetry ingestion, file uploads in chunks, batch imports where the server only answers once.
  • Bidirectional — chat, collaborative editing, control channels between an agent and a coordinator.

A server streaming handler in Go is just a loop over a send channel:

go
1func (s *Server) WatchOrders(
2	req *ordersv1.WatchOrdersRequest,
3	stream ordersv1.OrderService_WatchOrdersServer,
4) error {
5	events, err := s.bus.Subscribe(stream.Context(), req.GetCustomerId())
6	if err != nil {
7		return status.Errorf(codes.Internal, "subscribe: %v", err)
8	}
9
10	for {
11		select {
12		case <-stream.Context().Done():
13			return stream.Context().Err() // client hung up or deadline hit
14		case ev, ok := <-events:
15			if !ok {
16				return nil
17			}
18			if err := stream.Send(ev); err != nil {
19				return err
20			}
21		}
22	}
23}

✅ When gRPC Makes Sense

Internal service-to-service communication. Both sides are yours, both get regenerated from the same .proto, and the traffic volume is high enough that binary framing matters.

Polyglot teams. A Go service, a Python ML worker, and a Java billing system all generate from one contract. Nobody writes a client by hand or maintains three drifting SDKs.

Streaming is part of the domain. If your feature list includes "live", "progress", or "tail", you're going to build streaming anyway. gRPC gives it to you with the same auth, tracing, and error model as everything else.

Low-latency, high-fan-out paths. A gateway calling twelve services per request benefits from multiplexing over persistent connections much more than a single-call endpoint does.

Strict contracts are a requirement. Regulated domains where "the field silently changed shape" is an incident, not a bug.

🚫 When REST Is Still the Right Choice

Public APIs. Your users want curl, Postman, and a browser tab. They don't want to install protoc to try your API. Every public API you admire is REST or GraphQL for a reason.

Browser clients without a gateway. Browsers can't speak raw gRPC — no access to HTTP/2 trailers or frame control. You need gRPC-Web or Connect plus a proxy. That's real infrastructure.

Simple CRUD with low traffic. An admin panel backend does not need code generation and a proto registry. JSON over HTTP/1.1 is fine and everyone already knows it.

Heavy caching via HTTP semantics. CDNs, ETag, Cache-Control, and conditional requests are REST's home turf. gRPC has no equivalent story.

Webhooks and third-party integrations. Nobody's Zapier-equivalent will POST protobuf to you.

🌐 gRPC-Web and Connect

The browser problem is real, and there are three answers.

gRPC-Web is the original: a modified protocol the browser can produce, plus a proxy (Envoy or the Go grpcweb wrapper) that converts it to real gRPC. It works, but it does not support client streaming or bidirectional streaming — only unary and server streaming.

Connect (from Buf) is the pragmatic modern option. A Connect server speaks gRPC, gRPC-Web, and its own HTTP/JSON protocol on the same port. That means your browser clients get generated, type-safe TypeScript, and you can still curl an endpoint during debugging.

bash
1curl -X POST https://api.example.com/orders.v1.OrderService/GetOrder \
2  -H "Content-Type: application/json" \
3  -d '{"orderId": "ord_123"}'

A hand-written REST gateway — or grpc-gateway, which generates one from proto annotations. More moving parts, but gives you a genuinely REST-shaped public surface with proper paths and verbs.

OptionBrowser supportStreamingExtra infracurl-able
gRPC-WebYesServer streaming onlyProxy requiredNo
ConnectYesServer streamingNoneYes
grpc-gatewayYesLimited (SSE-style)Generated gatewayYes
Raw gRPCNoAll fourVia grpcurl

🛠️ Tooling and Code Generation

Raw protoc with a wall of plugin flags is the reason many people bounce off gRPC. Use buf instead.

yaml
1# buf.gen.yaml
2version: v2
3plugins:
4  - remote: buf.build/protocolbuffers/go
5    out: gen
6    opt: paths=source_relative
7  - remote: buf.build/grpc/go
8    out: gen
9    opt: paths=source_relative
10  - remote: buf.build/bufbuild/es
11    out: gen/ts

Then the whole workflow is three commands:

bash
1buf lint                                  # style and naming rules
2buf breaking --against '.git#branch=main' # compatibility gate
3buf generate                              # produce all languages

What belongs in CI:

  1. buf lint — enforces consistent naming before it becomes a public contract.
  2. buf breaking — fails the PR on an incompatible schema change.
  3. buf generate + a dirty-tree check — proves the committed generated code matches the proto.
  4. Publish to a schema registry (BSR or your own) so consumers pull versioned modules instead of copying .proto files around.

For debugging, grpcurl is your curl — and with server reflection enabled, it needs no local .proto:

bash
1grpcurl -plaintext localhost:9090 list
2grpcurl -plaintext -d '{"orderId":"ord_123"}' localhost:9090 orders.v1.OrderService/GetOrder

⚙️ Operational Realities

This is the section benchmark posts skip, and it's where adoption actually succeeds or fails.

🔁 Load Balancing Is Different

gRPC holds one long-lived HTTP/2 connection and multiplexes every call over it. An L4 load balancer balances connections, so one client pins to one backend and stays there — new replicas receive no traffic.

Fixes, in order of preference: an L7 proxy that balances per-request (Envoy, Linkerd, a service mesh), client-side load balancing with DNS or xDS resolution, or — as a blunt fallback — MAX_CONNECTION_AGE on the server so connections periodically recycle.

⏱️ Deadlines, Not Timeouts

Every gRPC call carries a deadline, and it propagates across hops. A 2-second deadline at the edge becomes a shrinking budget for every downstream call, and each one cancels itself when it expires.

go
1ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
2defer cancel()
3
4order, err := client.GetOrder(ctx, &ordersv1.GetOrderRequest{OrderId: id})

🚨 The Error Model

gRPC has a fixed set of status codes — richer than HTTP's for RPC purposes, and unambiguous about retryability:

CodeMeaningRetry?
INVALID_ARGUMENTClient sent bad dataNo
NOT_FOUNDResource doesn't existNo
PERMISSION_DENIEDAuthenticated but not authorizedNo
RESOURCE_EXHAUSTEDRate limited or quota hitYes, with backoff
FAILED_PRECONDITIONState is wrong for this operationNo, fix state first
UNAVAILABLETransient — server down, connection lostYes
DEADLINE_EXCEEDEDBudget ran outOnly if idempotent

Use google.rpc.Status details to attach structured error payloads — field-level validation errors, retry-after hints — instead of stuffing a message string.

👁️ Observability

Interceptors are gRPC's middleware, and they're where tracing, metrics, auth, and logging belong:

go
1srv := grpc.NewServer(
2	grpc.ChainUnaryInterceptor(
3		otelgrpc.UnaryServerInterceptor(),
4		authInterceptor(verifier),
5		loggingInterceptor(logger),
6		recoveryInterceptor(),
7	),
8)

OpenTelemetry has first-class gRPC instrumentation, and trace context propagates through metadata the same way it does through HTTP headers. Add health checking (grpc.health.v1.Health) so Kubernetes probes work — plain TCP probes will happily report a wedged server as healthy.

🔄 A Realistic Migration Path

You don't rewrite. You do this:

  1. Pick the hottest internal call path. The one where latency and call volume actually hurt. Not the greenfield side project.
  2. Write the .proto from the existing JSON contract. Field-for-field first; resist redesigning the API in the same step.
  3. Run both. Serve gRPC on a new port while REST keeps serving. Both handlers call the same domain layer — the transport is the only thing that differs.
  4. Move one consumer. Generate its client, switch it, and compare latency and error rates against the REST baseline for a week.
  5. Fix the operational gaps. This is where you discover your load balancer is L4 and your dashboards don't parse gRPC status codes. Better to find it with one consumer than twenty.
  6. Migrate the remaining consumers, then delete the REST handler. Keep REST at the public edge; remove it only internally.

🎯 Decision Guide

Your situationChoose
Internal microservices, high call volumegRPC
Public API for third-party developersREST
Browser frontend, no proxy budgetConnect or REST
Streaming or live updates in the domaingRPC
Polyglot services sharing one contractgRPC
Low-traffic CRUD, small team, tight deadlineREST
Heavy CDN and HTTP caching requirementsREST
Mobile clients on flaky networks, bandwidth-sensitivegRPC

🏁 Conclusion

gRPC isn't a faster REST. It's a different set of trade-offs: you give up human readability, browser-native access, and HTTP caching, and you get a machine-checked contract, real streaming, propagating deadlines, and generated clients in every language you need.

Ask two questions. Who is on the other end of this API? If it's your own services, gRPC's costs are cheap and its benefits compound. If it's the public internet, REST's ubiquity is worth more than any benchmark. What does my latency budget actually look like? If serialization and connection setup aren't a visible slice of it, adopt gRPC for the contract discipline, not the speed — and be honest with your team that that's why.

The architecture most teams land on isn't a choice between the two: REST at the edge, gRPC behind it. That's not a compromise, it's each protocol doing what it's good at.


❓ Frequently Asked Questions

Q: Is gRPC really 10× faster than REST?

A: In microbenchmarks with tiny payloads, sometimes. In a real service where you spend 40ms in the database, the serialization savings are noise. The reliable gains are smaller payloads (30–60% vs JSON), lower CPU on serialization, and fewer connection round-trips. Measure your own latency breakdown before claiming a number.

Q: Can I call a gRPC service directly from a browser?

A: Not raw gRPC — browsers don't expose the HTTP/2 control needed. Use Connect (gRPC, gRPC-Web, and HTTP/JSON on one port, no proxy), gRPC-Web with an Envoy proxy, or grpc-gateway to generate a REST facade.

Q: How do I debug gRPC without curl?

A: Use grpcurl. With server reflection enabled it discovers the schema from the running server, so you can list services and invoke methods with JSON input. If you're on Connect, plain curl with Content-Type: application/json works too.

Q: What happens if I add a field to a message?

A: Nothing breaks. Old clients ignore unknown fields, and new fields decode to their zero value on old data. The dangerous changes are reusing a field number, changing a field's type, or removing a field without reserving its number — run buf breaking in CI to catch all three.

Q: Do I need a service mesh to run gRPC?

A: No, but you need something that balances per-request rather than per-connection. A mesh (Linkerd, Istio) is the easy answer; an Envoy sidecar or client-side load balancing with DNS resolution works too. Plain L4 load balancing will pin each client to one backend.

Q: Should I set deadlines on every call?

A: Yes. A call without a deadline waits indefinitely, and one slow dependency will exhaust your service's resources. Set the budget at the edge and let it propagate — every downstream hop inherits the remaining time and cancels itself when it runs out.

Q: Is GraphQL a better alternative to both?

A: Different problem. GraphQL solves client-driven field selection and over-fetching, which is a frontend concern. gRPC solves typed, fast, streaming service-to-service communication. They coexist happily: GraphQL at the edge for the frontend, gRPC behind it between services.

Q: How do I version a gRPC API?

A: Version the proto package (orders.v1, orders.v2), not individual methods. Add fields within a version as long as they're backward compatible; only cut a new package when you genuinely break the contract. Run both versions side by side during migration — they're separate service definitions and can share the same implementation underneath.

Follow us
All Rights Reserved
© 2011-2026
Progressive Innovation
LAB