open atlas
↑ Back to track
Go, zero to senior GO · 09 · 01

gRPC and protobuf: the compiled contract, HTTP/2 transport, and the balancer that ignored your new pods

A .proto file compiles into client and server stubs; field tags, not names, are the wire contract, and tags 1-15 encode in one byte. HTTP/2 multiplexes every RPC over one connection — so an L4 balancer pins all load to one backend — and deadlines propagate across hops natively.

GO Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

At the peak of a flash sale the checkout team scaled the inventory service from three pods to ten and watched nothing change: three pods pegged at 95% CPU, seven new ones idling at 2%. The Service in front of them was a plain Kubernetes ClusterIP — a layer-4 balancer that distributes TCP connections. Each gRPC client had opened exactly one HTTP/2 connection at startup and was multiplexing every RPC over it, so the balancer had made its one decision per client days ago; the new pods never saw a single stream. Someone restarted a client pod to “fix” it — its fresh connection landed on a hot backend, making things worse. The actual fix was two lines: a headless Service so DNS returns every pod IP, and round_robin in the client’s service config so the gRPC channel itself balances per call. The postmortem line that stuck: we scaled the pool, but the protocol had already decided who does the work.

The contract compiles in both directions

By the end of this section you will know exactly why renaming a field is free and changing its tag is not — and why that distinction determines what your load balancer sees.

REST contracts live in documentation and drift; a gRPC contract is a file that compiles into both sides of the wire:

syntax = "proto3";
package inventory.v1;

service Inventory {
  rpc GetStock(GetStockRequest) returns (StockReply);
  rpc WatchStock(WatchRequest) returns (stream StockReply); // server streaming
}

message GetStockRequest {
  string sku = 1;       // tag 1 — the number IS the field's identity
  string warehouse = 2;
}

Run buf generate (or raw protoc with the Go plugins) and you get two artifacts: a typed client stub whose methods serialize, dispatch, and decode for you, and a server interface — InventoryServer — that your code must implement. The compiler, not a wiki page, enforces that both teams agree on the shape.

The detail that changes how you think about protobuf: field names never reach the wire. Each encoded field is a key — the tag number shifted left three bits, OR-ed with a wire type — followed by the value. Rename sku to stock_unit and the bytes are identical; change the tag from 1 to 9 and you have silently created a different field. The key is a varint, which is why tag values matter: tags 1 through 15 fit the key in a single byte, 16 through 2047 take two. On a message logged millions of times per minute, giving a hot repeated field tag 16 instead of tag 3 is a measurable storage tax. Plan tag space like you plan an API: small tags for hot fields, and leave gaps for the future.

Honest numbers: protobuf payloads typically come out 2–5× smaller than the equivalent JSON and parse roughly 5–10× faster, because decoding is arithmetic on tagged binary instead of text scanning. But gzip eats much of the size advantage on large payloads — compressed JSON often lands within 1.5–2× of compressed protobuf. The wins that survive compression are parse cost, allocation behavior, and the generated types: nobody writes json.RawMessage archaeology against a gRPC API.

Quiz

A teammate renames the protobuf field sku to stock_unit but keeps tag 1, regenerates the Go code, and deploys only the server. What happens to old clients still sending the old field?

One connection, multiplexed — and what your balancer sees

gRPC runs on HTTP/2: the client opens one TCP connection and every RPC becomes a stream on it, frames from concurrent calls interleaving freely. No new handshake per call, no connection-pool tuning, and request 2 does not wait for request 1 at the HTTP layer (packet loss still stalls all streams at the TCP layer — HTTP/2 fixed HTTP head-of-line blocking, not TCP’s).

That efficiency has a production consequence the Hook paid for. A layer-4 balancer — Kubernetes ClusterIP, AWS NLB — balances at connection-accept time. A gRPC client presents exactly one long-lived connection, so all of its RPCs pin to whichever backend accepted it. Scaling out adds pods that no existing client will ever talk to. You have three real options:

  • Client-side balancing: a headless Service (DNS returns all pod IPs) plus "loadBalancingConfig": [{"round_robin":{}}] — the channel maintains a sub-connection per backend and spreads calls. Caveat: DNS re-resolution is lazy; pair it with server-side MaxConnectionAge so connections recycle and discover new pods.
  • An L7 proxy (Envoy, Linkerd): the proxy speaks HTTP/2 on both sides and balances per stream, not per connection. This is the mesh answer and costs you a hop.
  • Doing nothing is also a decision — fine for two pods and fatal for autoscaling.

Together these options span the spectrum from zero infrastructure change to full service mesh. When you scale a gRPC service and traffic distribution does not follow, the cause is almost always that you chose the third option without knowing it.

Four call types, deadlines that travel, and codes that lie to dashboards

The stream keyword generalizes RPC into four shapes: unary (one request, one response — most calls), server streaming (one request, many responses: watches, feeds, paging a huge result set without buffering it), client streaming (many requests, one response: uploads, telemetry batching), and bidirectional (both at once: chat, sync protocols, live transcription). Generated Go code gives streams Send and Recv methods over the same context machinery you already know.

Deadlines are where gRPC quietly beats hand-rolled HTTP conventions. Set one on the client context and it is transmitted as the grpc-timeout header on the call — and on every downstream call made from that context, with the remaining budget. A 500ms edge deadline arrives at the third hop as whatever is left, and every service fails fast together with DeadlineExceeded. With plain HTTP you reinvent this with custom headers and team discipline; gRPC ships it in the runtime.

Two more things senior engineers get burned by. First, gRPC status codes are not HTTP status codes: the HTTP layer says 200 for nearly everything, and the real verdict — OK, Unavailable, DeadlineExceeded, NotFound — rides in the grpc-status trailer. A dashboard counting HTTP 200s on a gRPC service is measuring nothing. Second, cross-cutting concerns go in interceptors — the middleware of gRPC, client and server, unary and stream variants:

func authInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo,
	next grpc.UnaryHandler) (any, error) {
	if err := verifyToken(ctx); err != nil {
		return nil, status.Error(codes.Unauthenticated, "bad token")
	}
	return next(ctx, req) // chain continues — same shape as http middleware
}
Why this works

Why does the wire format use varint keys at all, instead of fixed-width field IDs? Because protobuf was designed for messages that are mostly small integers and short strings, logged at Google scale: a one-byte key for the fifteen hottest fields beats a uniform four-byte key by 20–30% on typical payloads, and the decoder stays a tight loop of read-varint, switch-on-wire-type. The cost is the rule this whole unit orbits: the tag number is sacred, because it is the only identity a field has.

Quiz

An edge service calls orders with a 500ms deadline; orders spends 300ms, then calls pricing using the same ctx. What deadline does pricing observe, and via what mechanism?

Recall before you leave
  1. 01
    Explain why renaming a protobuf field is wire-safe but changing its tag is not, down to the encoding.
  2. 02
    A gRPC service behind a Kubernetes ClusterIP does not rebalance when you scale pods. Walk the mechanism and the two fixes.
Recap

gRPC inverts where the contract lives: instead of documentation describing an HTTP API, a .proto file compiles into the client stub and the server interface, and disagreement between teams becomes a build failure. On the wire, fields are tag-plus-wire-type keys — names are decoration that never leaves the process, which is why renames are free and tag changes are contract changes. Varint keys make tags 1 through 15 a one-byte cost, so hot fields get small numbers. Honest performance: 2–5× smaller than JSON and 5–10× faster to parse, but gzip narrows the size gap; the durable wins are parse cost and generated types. Transport is HTTP/2 with every RPC as a stream over one connection — efficient, and the direct cause of the classic incident where scaling pods changes nothing because an L4 balancer assigns whole connections; the cures are client-side round_robin over headless DNS with MaxConnectionAge, or an L7 proxy that balances per stream. The stream keyword gives four call shapes — unary, server streaming for watches and feeds, client streaming for uploads, bidi for chat-like protocols. Deadlines set on a context travel as grpc-timeout with the remaining budget at every hop, so the whole call tree fails together — the thing HTTP teams hand-roll badly. Status codes live in trailers while HTTP reports 200, so dashboards must count grpc-status, and interceptors carry the cross-cutting middleware exactly like http.Handler wrappers did. Now when you see new pods sitting idle while old ones are saturated, you will know to look at how the channel was built — not at the pods themselves.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.