gRPC's advantages are easy to demonstrate and easy to sell: binary encoding, HTTP/2 multiplexing, generated clients in every language, real streaming. The costs arrive later and in a less quotable form - at three in the morning, when a service returns INTERNAL with no message, your packet capture is unreadable binary, and the load balancer you configured months ago has been sending every request to one pod.
None of that is an argument against gRPC. It is an argument for going in with the operational bill itemised. This covers Protocol Buffers and the rules that keep a schema evolvable, the four streaming modes and when each is right, contract tooling, the observability you need before you ship, the browser situation as it actually stands, and the cases where plain HTTP is the better answer.
What gRPC actually is
Three things bolted together: Protocol Buffers as the serialisation format and interface definition language, HTTP/2 as the transport, and a code generator that turns a .proto file into a typed client and server stub in your language.
The mental model is a function call, not a resource. You do not GET /orders/42; you call OrderService.GetOrder(GetOrderRequest{id: 42}) and get back a typed GetOrderResponse. That framing is the source of both its ergonomics and its opacity - a function call has no URL, no readable body, and nothing a browser can show you.
syntax = "proto3";
package shop.orders.v1;
option go_package = "github.com/acme/shop/gen/orders/v1;ordersv1";
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc WatchOrders(WatchOrdersRequest) returns (stream OrderEvent);
}
message Order {
string id = 1;
string customer_id = 2;
OrderStatus status = 3;
int64 total_cents = 4;
google.protobuf.Timestamp placed_at = 5;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_PAID = 2;
ORDER_STATUS_SHIPPED = 3;
}shop.orders.v1 and shop.orders.v2 are different namespaces that can run side by side in one binary.The four call types
| Type | Signature | Use it for |
|---|---|---|
| Unary | rpc Get(Req) returns (Resp) | Ordinary request/response - the large majority of RPCs |
| Server streaming | returns (stream Resp) | Feeds, tailing logs, large result sets, progress updates |
| Client streaming | (stream Req) returns (Resp) | Uploads, batch ingestion, metric reporting |
| Bidirectional | (stream Req) returns (stream Resp) | Chat, live collaboration, long-lived control channels |
Protocol Buffers
Protobuf is the part that pays off and the part that bites. It is a binary format where field names do not appear on the wire - only field numbers, types and values. That is why messages are small and parsing is fast, and also why a captured packet means nothing without the schema that produced it.
Field numbers are the contract
message Order {
string id = 1; // 1 through 15 use a single byte for the tag
string customer_id = 2; // use them for your most frequent fields
int64 total_cents = 4;
reserved 3; // was `status_code`, removed in v1.4
reserved "status_code"; // stop anyone reusing the name either
}reserved is not optional hygiene. Reuse a retired field number and an old client will decode new data into the old field's type - silent corruption rather than an error.| Safe | Breaking |
|---|---|
| Adding a new field with a new number | Changing a field's number |
Removing a field, if you reserve the number | Removing a field without reserving |
| Renaming a field (names are not on the wire) | Changing a field's type, with narrow exceptions |
| Adding a value to an enum | Removing or renumbering an enum value |
| Adding a new RPC to a service | Removing or renaming an RPC |
int32 ↔ int64 ↔ bool (compatible varints) | int32 ↔ string, or optional ↔ repeated |
The zero-value problem
In proto3, scalar fields have no explicit presence by default. A field set to 0, false or "" is indistinguishable from a field never set at all - both are absent from the wire and both decode to the zero value.
message UpdateOrderRequest {
string id = 1;
int64 total_cents = 2; // 0 means "unset" OR "set it to zero"
optional int64 discount_cents = 3; // `optional` restores explicit presence
google.protobuf.FieldMask update_mask = 4; // or say which fields you meant
}Editions, and why you can probably ignore them
Protobuf Editions replace the syntax = "proto2" / syntax = "proto3" declarations with edition = "2024". Instead of two hardcoded behaviour sets, each behaviour - field presence, enum openness, repeated encoding - becomes a feature you can configure at file, message or field level.
edition = "2024";
package shop.orders.v1;
message Order {
// field_presence defaults to EXPLICIT in editions,
// which is proto2 behaviour rather than proto3's
string id = 1;
int64 total_cents = 2 [features.field_presence = IMPLICIT];
}Service contracts and the tooling that enforces them
The .proto file is the contract, and unlike an OpenAPI document it is not a description of the implementation - it generates it. That is a real advantage: drift between spec and code is structurally impossible.
What is not automatic is preventing a breaking change from reaching a consumer. buf is the standard toolchain for this, and the breaking-change check is the part worth adopting even if you use nothing else.
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
except:
- FIELD_SAME_DEFAULTbuf lint # naming, package structure, style
buf breaking --against '.git#branch=main' # fails on an incompatible change
buf generate # all languages, one config
buf format -w
# Publish to a registry so consumers depend on a version, not a copied file
buf pushbuf breaking in CI is the single highest-value item in this article. It converts "we renumbered a field" from a production data-corruption incident into a failed pull request.Conventions that pay off
- Version in the package path -
shop.orders.v1. It costs nothing on day one and is the only mechanism that lets v1 and v2 coexist in one process. - A request and response message per RPC, always, even when one field would do.
GetOrderRequestcan grow; a barestringcannot. - Enum zero value is
UNSPECIFIED. Proto3 enums default to zero, so making zero a meaningful value means you can never distinguish unset from that value. - Prefix enum values with the enum name. Protobuf enum values share a C++-style namespace with their parent scope, so
PENDINGin two enums in one package collides. - Distribute schemas through a registry, not by copying files between repositories. A copied
.protodrifts, and nothing detects it. - Use the well-known types -
Timestamp,Duration,FieldMask,Struct- rather than inventing your own.
Streaming, and when it is worth it
Streaming is gRPC's clearest advantage over request/response HTTP, and the feature most often adopted for the wrong reason. A stream is a long-lived logical channel over an HTTP/2 stream, with backpressure and flow control handled for you.
// Server streaming: push events as they happen
func (s *Server) WatchOrders(
req *ordersv1.WatchOrdersRequest,
stream ordersv1.OrderService_WatchOrdersServer,
) error {
events, cleanup := s.bus.Subscribe(req.GetCustomerId())
defer cleanup()
for {
select {
case <-stream.Context().Done():
// Client went away, or the deadline expired. Stop immediately.
return stream.Context().Err()
case ev := <-events:
if err := stream.Send(ev); err != nil {
return err
}
}
}
}stream.Context().Done(). A stream handler that only watches its data source leaks a goroutine for every client that disconnects uncleanly - which is all of them, eventually.| Situation | Streaming or unary? |
|---|---|
| Result set larger than comfortable memory | Server streaming - the consumer processes as it arrives |
| Live events pushed to a service | Server streaming, or a message broker if durability matters |
| Large file or batch upload | Client streaming, chunked |
| A few hundred rows | Unary. Streaming adds complexity for no gain |
| Work that must survive a restart | Neither - that is a queue, not a stream |
| Request/response that happens to be slow | Unary with a longer deadline |
Long-lived streams also interact badly with infrastructure that assumes short requests. Load balancer idle timeouts, proxy read timeouts and Kubernetes rolling restarts will all sever streams. Design for reconnection from the start rather than discovering it during a deployment.
Errors, deadlines and retries
Status codes
gRPC has its own status code set, deliberately smaller than HTTP's. The mapping to HTTP status codes is lossy in both directions, which matters when you put a gateway in front.
| Code | Meaning | Retry? |
|---|---|---|
OK | Success | - |
INVALID_ARGUMENT | Client sent something wrong. Fix and resend | No |
NOT_FOUND | No such entity | No |
ALREADY_EXISTS | Conflict on a unique constraint | No |
PERMISSION_DENIED | Authenticated but not allowed | No |
UNAUTHENTICATED | Missing or invalid credentials | No |
RESOURCE_EXHAUSTED | Rate limited or out of quota | Yes, with backoff |
FAILED_PRECONDITION | System state is wrong. Do not retry blindly | No |
ABORTED | Concurrency conflict - transaction abort | Yes, at a higher level |
UNAVAILABLE | Transient. The canonical retryable code | Yes |
DEADLINE_EXCEEDED | Ran out of time | Sometimes - it may have succeeded |
INTERNAL | A bug on the server side | No |
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
epb "google.golang.org/genproto/googleapis/rpc/errdetails"
)
func notEnoughStock(sku string, want, have int32) error {
st := status.New(codes.FailedPrecondition, "insufficient stock")
detailed, err := st.WithDetails(&epb.ErrorInfo{
Reason: "INSUFFICIENT_STOCK",
Domain: "shop.acme.com",
Metadata: map[string]string{
"sku": sku,
"requested": fmt.Sprint(want),
"available": fmt.Sprint(have),
},
})
if err != nil {
return st.Err() // detail encoding failed; the base status still works
}
return detailed.Err()
}ErrorInfo.Reason is the stable machine-readable identifier - the equivalent of the type URI in an RFC 9457 problem document. Clients branch on it; the human message stays free to change.Deadlines propagate, and that is the point
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
order, err := client.GetOrder(ctx, &ordersv1.GetOrderRequest{Id: id})- Always set a deadline. A client without one waits forever, and one stuck call holds a connection, a goroutine and a database handle indefinitely.
- Set it at the edge and let it propagate, rather than picking an arbitrary timeout per hop.
- Check the deadline server-side before starting expensive work. If less than the operation needs remains, fail fast rather than doing work that will be discarded.
DEADLINE_EXCEEDEDdoes not mean it did not happen. The write may have succeeded after you stopped listening - which is why idempotency keys matter here exactly as they do in REST.
Retries belong in configuration
{
"methodConfig": [{
"name": [{ "service": "shop.orders.v1.OrderService" }],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "2s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
}
}]
}UNAVAILABLE and RESOURCE_EXHAUSTED is deliberate - anything else risks duplicating a write.The debugging problem, and what fixes it
This is the section that earns the title. gRPC's opacity is not a minor inconvenience - it invalidates a set of habits every engineer has, and you have to replace them deliberately.
| Habit that stops working | What replaces it |
|---|---|
curl the endpoint | grpcurl, which needs reflection enabled or the .proto files |
| Read the response body in a log | Structured logging of decoded messages, written by you |
| Eyeball a packet capture | Wireshark with the schema loaded, or nothing |
| Filter proxy logs by URL path | Paths are /package.Service/Method - usable, but not in most dashboards by default |
| Group metrics by HTTP status | gRPC status codes, which your APM may not break out by default |
| Browser dev tools | Nothing. There is no equivalent |
Turn on reflection, at least outside production
# With reflection enabled, grpcurl needs nothing else
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 list shop.orders.v1.OrderService
grpcurl -plaintext localhost:50051 describe shop.orders.v1.Order
grpcurl -plaintext \
-d '{"id": "ord_42"}' \
localhost:50051 shop.orders.v1.OrderService/GetOrder
# Without reflection, point it at the schema
grpcurl -import-path ./proto -proto shop/orders/v1/orders.proto \
-d '{"id": "ord_42"}' api.internal:443 shop.orders.v1.OrderService/GetOrderWhat to instrument before you ship
- OpenTelemetry interceptors on both client and server. gRPC metadata carries trace context cleanly, so distributed tracing works well - but only if you wire it up. A gRPC call graph without tracing is genuinely hard to reason about.
- Metrics broken out by method and status code, not aggregated per service.
OrderServicebeing healthy tells you nothing ifCreateOrderis failing. - Log the method, status code, deadline remaining and a correlation ID on every call. Put the correlation ID in metadata so it propagates.
- Health checking via the standard
grpc.health.v1.Healthservice, so your orchestrator probes the same path as real traffic. - channelz for connection-level state - which subchannels exist, which are ready, what the load balancer is actually doing. It answers questions nothing else can.
- Payload logging behind a flag, sampled, with sensitive fields redacted. When you need to see a message body, you need it immediately.
import "google.golang.org/grpc/stats/opentelemetry"
server := grpc.NewServer(
opentelemetry.ServerOption(opentelemetry.Options{
MetricsOptions: opentelemetry.MetricsOptions{MeterProvider: mp},
}),
)
// Reflection and health, both worth having from day one
reflection.Register(server)
healthpb.RegisterHealthServer(server, health.NewServer())The load balancing trap
This one deserves its own section because it silently produces the exact opposite of what you configured, and teams often run in that state for months.
gRPC uses long-lived HTTP/2 connections and multiplexes many calls over each one. A layer-4 load balancer balances connections, not requests. So a client that opens one connection sends every subsequent RPC to whichever backend that connection landed on - while your dashboard shows a load balancer distributing traffic evenly across connections that no longer reflect load.
| Approach | How it works | Notes |
|---|---|---|
| L7 proxy | Envoy, Linkerd, NGINX with gRPC support - balances individual streams | The standard answer; also gives you retries, mTLS and telemetry |
| Client-side load balancing | Client resolves all backends and picks per call | Lowest latency, no extra hop; the client must track membership |
| Service mesh | A sidecar does L7 balancing transparently | Correct by default; a large operational commitment |
| Periodic reconnection | MAX_CONNECTION_AGE forces clients to redial | A mitigation, not a fix - worth setting regardless |
| L4 load balancer alone | Balances connections | Effectively pins traffic. Do not ship this |
server := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionAge: 30 * time.Minute,
MaxConnectionAgeGrace: 5 * time.Minute,
Time: 30 * time.Second,
Timeout: 10 * time.Second,
}),
)MaxConnectionAge sends a graceful GOAWAY so clients redial, which redistributes them. The grace period lets in-flight calls finish. This helps even with an L7 proxy, because it also rebalances after a scale-up.The browser situation
Browsers cannot speak gRPC. Not for lack of trying - the fetch API and XHR do not expose the control over HTTP/2 frames that the protocol requires. Three workarounds exist, and their trade-offs are different enough to matter.
| Option | How it works | Cost |
|---|---|---|
| gRPC-Web | A modified protocol plus a proxy (Envoy or similar) that translates to real gRPC | A proxy in the path; no client streaming, no bidirectional streaming |
| Connect | An HTTP-idiomatic protocol from Buf; servers speak Connect, gRPC and gRPC-Web at once | A different protocol, though wire-compatible with gRPC; no proxy needed |
| A REST or GraphQL gateway | Translate at the edge; browsers never see gRPC | A layer to write and maintain, and a second contract |
Connect is the most interesting of the three. A Connect server responds to all three protocols and the client selects via Content-Type, so Node-to-Node services can use native gRPC while the browser talks Connect against the same .proto contract and the same server. Unary Connect calls are notably plain - no length-prefix framing, the serialised message is simply the HTTP body - which is exactly why they work with ordinary HTTP tooling.
The practical shape most teams land on: gRPC between internal services, and a REST, GraphQL or Connect layer at the edge for browsers. That is not a compromise so much as correct placement - the edge has different requirements from the interior.
Versioning and evolution
Protobuf's compatibility rules do most of the work, which means the majority of changes need no version bump at all. Add fields freely; old clients ignore what they do not know about, and new servers see zero values for what old clients omit.
- Add, never modify. New field, new number. Deprecate the old one with
[deprecated = true]and remove it in a later major version, reserving the number. - Version the package, not the service.
shop.orders.v1andshop.orders.v2are distinct namespaces, so one binary can serve both during a migration. - Run v1 and v2 together. Register both implementations, have v1 delegate to v2 where possible, and retire v1 when usage analytics say it is safe.
- Track per-method usage by client. Same requirement as GraphQL field analytics - without it, deprecation never leads to removal.
- Never reuse a field number. Ever.
reservedexists precisely so the compiler enforces this after everyone has forgotten why. - Treat enums as open. A client may receive a value it was not compiled with. Handle the unknown case explicitly rather than assuming exhaustiveness.
When plain HTTP is enough
gRPC is a strong default between internal services in a polyglot estate. It is a poor default almost everywhere else, and the reasons are practical rather than philosophical.
- Public or partner APIs. Third parties want
curl, a browser, Postman and a README. Handing them a.protofile and a code generation step is a real adoption cost with no benefit to them. - Browser-facing endpoints. You need a proxy, a translation layer or Connect. If that is the only reason you would run a proxy, use HTTP.
- A handful of services, one language. The generated-client advantage is largest across language boundaries. Within one language, a typed HTTP client gets you most of it for far less machinery.
- Cache-heavy reads. HTTP caching at the CDN is free and effective. gRPC has no equivalent, and rebuilding it is not worth doing.
- File uploads and downloads. Chunked client streaming works and is more code than a signed URL and a
PUT. - Webhooks and callbacks. The receiver is someone else's system. It wants JSON over HTTP.
- Low call volume. If you make forty inter-service calls a second, the serialisation and transport savings are noise against the operational cost.
- No capacity for the tooling. gRPC without
buf breaking, tracing, L7 balancing and structured errors is worse than REST, not better.
The honest summary of the performance argument: binary encoding and HTTP/2 multiplexing produce real savings in CPU and bytes, and those savings scale with call volume. Published comparisons vary enormously depending on payload size, language runtime and whether the REST baseline uses HTTP/2 and connection pooling. Measure your own workload before treating any specific multiplier as a reason to migrate.
Mistakes that keep recurring
| Mistake | Consequence |
|---|---|
| Reusing a retired field number | Old clients decode new data into the wrong field - silent corruption |
No buf breaking in CI | The above reaches production before anyone notices |
| L4 load balancing in front of gRPC | All traffic pinned to one backend while the dashboard looks fine |
| No deadlines on client calls | Stuck calls hold connections and goroutines indefinitely |
Returning bare INTERNAL | Callers cannot distinguish a bug from a transient fault |
| Retrying non-idempotent methods | Duplicate writes, most visibly duplicate payments |
| Streaming without watching the context | A leaked goroutine for every disconnected client |
| Enum zero value with meaning | You can never distinguish unset from that value |
Copying .proto files between repositories | Silent drift with nothing to detect it |
| No reflection anywhere, including staging | Every debugging session starts by locating the right schema version |
| Treating a stream as a durable queue | Messages lost on every reconnect |
| Adopting gRPC for two services in one language | All of the machinery, none of the benefit |
Verdict
gRPC is the right choice for internal service-to-service communication at meaningful volume, especially across languages. The contract is generated rather than described, so drift is impossible. Deadlines propagate through the call graph. Streaming is first-class instead of bolted on. Those are real engineering advantages and they compound as the estate grows.
The bill is paid in observability. Every debugging habit that relies on reading text off the wire stops working, and you replace it with tracing, structured logging, reflection and tooling you have to set up before you need it - not during the incident that made you want it.
Put
buf breakingin CI, deadlines on every call, an L7 proxy in front, and OpenTelemetry on both sides. Those four things are the difference between gRPC being fast and gRPC being a debugging nightmare.
And keep it inside. Internal services speak gRPC; the edge speaks HTTP. That boundary is not a compromise - it is putting each protocol where its trade-offs are the right ones. The teams who regret gRPC are almost always the ones who pushed it out to a browser or a third party and spent the next year maintaining a translation layer.
Sources
- gRPC documentation - concepts, guides, and per-language references
- Protocol Buffers documentation - language guides, wire format, and the editions overview
- Protobuf Editions - features, defaults, and the migration story
- Buf - linting, breaking-change detection, code generation and the schema registry
- Connect RPC - the browser-native protocol with gRPC compatibility
- grpcurl - the command-line client, with and without reflection
- Google API design guide - the AIP series, the best public writing on RPC contract design



