Field Numbers Are Forever: Evolving a .proto Without Breaking Decoders
syntax = "proto3";
message Order {
string order_id = 1; // numbers 1-15 encode in a single tag byte
int64 total_cents = 2;
repeated LineItem items = 5;
reserved 3, 4; // numbers of deleted fields — never reused
reserved "legacy_note"; // so the name cannot come back either
}
Protobuf encodes each field as a tag followed by a value, where the tag is (field_number << 3) | wire_type. That makes the field number the field's identity on the wire; the name exists only for generated code and the proto3-JSON mapping. Several production rules fall straight out of it.
Renumbering a field is the classic outage. The writer tags bytes as field 7; readers compiled against the new file expect field 7 to be absent and decode the value into whatever now owns that slot — or skip it entirely. Never renumber a deployed field. Deleting without reserved is a slower time bomb: months later someone adds a new field that lands on the vacated number, and old payloads decode garbage into it. reserved blocks both the numbers and the name from reuse.
Renaming is the subtle case: wire-compatible, because the number still identifies the field, but source-breaking and JSON-breaking — generated members rename, and any system speaking proto3-JSON sees the key change (order_id to orderId). Rename during a planned client release, never as a cosmetic commit.
| Change | Wire compatible? | What actually happens |
|---|---|---|
| Add a field with a new number | yes | old readers skip unknown fields; new readers see the default |
| Delete a field, reserve number + name | yes | without reserved, future reuse corrupts old payloads |
| Rename, keep the number | wire yes, JSON and codegen no | proto3-JSON keys and generated members change |
int32 to string |
no | varint versus length-delimited: decode fails or yields garbage |
int32 to int64 / bool |
wire yes, values need review | both varint, so nothing errors — out-of-range values truncate on read |
repeated int32 packed to unpacked |
yes | parsers accept both encodings |
scalar to map |
no | maps encode as repeated key/value messages |
scalar to optional |
semantics change | field presence appears; unset becomes distinguishable from zero |
That last row deserves emphasis: proto3 scalars have no presence by default, so a server cannot tell unset from set-to-zero. Money should be optional or a dedicated message — absent must never mean $0.00. Adding optional later changes what old code observes, so decide presence per field at design time.
This is also where the codegen payoff lives: one order.proto compiles into typed clients and servers across many languages — Go structs and clients, Java classes, TypeScript types — from a single contract. Regeneration is the workflow (buf generate in CI), and hand-editing generated code is never the fix.
Interviews love the phrasing "how do you evolve a proto in production?" Answer with: new numbers only, reserved on every deletion, widen rather than narrow, review value ranges, and run buf breaking against the last release in CI — the exact analogue of the spec diff in Contract-First with OpenAPI, which we will contrast with client-driven flexibility in GraphQL Trade-offs.
The JSON bridge deserves its own warning, because gateways, logs, and browser tools speak proto3-JSON: names map to camelCase, int64 serializes as a string (JavaScript numbers lose precision past 2^53), and enums become their names. Any system that round-trips through JSON must hold those rules too — the classic incident is a JSON consumer parsing a 64-bit id as a number and corrupting it silently. Keep identifiers as strings at every JSON boundary and state the mapping in the contract docs.
Unary, Server-Stream, or Bidirectional: Choosing the RPC Shape
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order); // unary
rpc WatchOrder (WatchRequest) returns (stream OrderEvent); // server stream
rpc ImportOrders (stream Order) returns (ImportSummary); // client stream
rpc Negotiate (stream Msg) returns (stream Msg); // bidirectional
}
gRPC runs over HTTP/2, and two properties of that transport shape every design decision here. A single TCP connection multiplexes many concurrent RPCs without HTTP/1 head-of-line stalls, and per-stream flow control gives backpressure that reaches the producer: when a consumer stops reading, the sender's window fills and it blocks instead of buffering without limit. That is the property a naive socket-based protocol never gives you.
Choose the shape by who talks first and who finishes last:
| Shape | Pattern | Typical use | Operational cost |
|---|---|---|---|
| Unary | one request, one response | CRUD, commands, lookups | lowest — plain L7 load balancing, easy retries |
| Server stream | request, many messages | log tailing, change feeds, job progress | one long-lived RPC to route and observe |
| Client stream | many messages, one summary | batch import, uploads | partial side effects if the stream dies mid-way |
| Bidirectional | both sides, concurrently | chat, live collaboration, market data | hardest: cancellation, timeouts, and LB all matter more |
Default to unary. Streaming complicates retries, load balancing, and observability (one RPC carries thousands of messages — count both), and a stream that fails after 10,000 messages leaves partial side effects that a single atomic outcome would not.
Three mechanics separate senior answers from basic ones. First, deadlines: the caller sets one, gRPC ships it as a grpc-timeout header, and the server must propagate it into its own downstream calls. Skip that and calls hang until thread pools drain — DEADLINE_EXCEEDED should be a designed outcome, not a surprise. Second, status codes: gRPC uses a canonical enum of fifteen values (NOT_FOUND, FAILED_PRECONDITION, RESOURCE_EXHAUSTED, ABORTED, UNAVAILABLE, DEADLINE_EXCEEDED...) instead of HTTP's loose collection, and clients switch on them — so choose deliberately. ABORTED tells a transactional client to retry after backoff; FAILED_PRECONDITION tells it not to bother; UNAVAILABLE is the signal a retry policy keys on. Third, retries live in the gRPC service config with retryableStatusCodes, capped by a retry budget — and only methods that are idempotent may be listed, the same rule that governs retries in REST Design and Versioning.
Beware one class of bug that only appears under streaming: flow control prevents unbounded buffering in the network layer, but a server that reads eagerly and hands work to an unbounded queue has simply moved the memory problem. Backpressure must propagate through your own code, not just the transport.
Metadata runs alongside every shape: gRPC carries custom headers before the message and trailers after it, so tracing context, tenant ids, and auth claims travel without polluting the message schema — but they are HTTP/2 headers, which means size limits apply and secrets should stay short-lived. Observability needs the pairing too: one RPC can carry ten thousand stream messages, so record both the RPC count and the message count, and treat a healthy RPC whose message rate has stalled as the failure it is.
Streams also complicate rebalancing. A unary call round-robins cleanly across resolved backends, while a long-lived stream opened before a new instance joined keeps its messages on the old one; rebalancing means closing and re-establishing streams. Design every consumer to reconnect with backoff and to resume from a cursor rather than assuming continuity — a subscription that cannot reconnect is a subscription that will eventually stop receiving updates while reporting no errors.
Where gRPC Stops: Browsers, Gateways, and Public-Facing Contracts
Unary gRPC is the default for internal service calls: compact binary payloads, generated typed clients in every language, deadlines as a first-class concept, and streams when you need them. The boundary question is where it stops working, and the answers are contractual, not religious.
Browsers. Web APIs expose neither HTTP/2 trailers — where gRPC places grpc-status — nor the flow-control surface gRPC-Web requires, so a browser cannot speak native gRPC. The options: gRPC-Web, where the client speaks a trimmed protocol and a proxy such as Envoy translates to full gRPC behind it; or the Connect protocol (connect-es and friends), which runs over ordinary fetch with JSON or protobuf and typically needs no sidecar. Plenty of teams skip all of it and serve REST/JSON to the web tier — a defensible answer, not a shortcut.
Public and mobile clients. gRPC works over hostile networks, but debugging binary frames with curl gets you nothing, middlebox behavior around HTTP/2 varies, and partner developers expect JSON they can read. The standard architecture keeps directions separate: east-west traffic inside the mesh is gRPC; north-south traffic at the edge is REST/JSON (or GraphQL), translated by grpc-gateway (HTTP reverse proxy generated from proto annotations), Envoy's HTTP/2-to-gRPC transcoding, or a hand-written facade owned by a backend-for-frontend.
Load balancing. This is the gotcha interviewers enjoy. A layer-4 balancer sees one connection; with HTTP/2, all streams land on one backend until that connection closes. Fixes: client-side round_robin over a resolvable endpoint list, an L7 balancer or mesh data plane that opens fresh upstream connections per request, or proxy-less discovery via xDS. Whatever you pick, choose it deliberately — the default quietly defeats your carefully built fleet.
Evolution tooling. buf lint enforces naming and package rules; buf breaking --against compares against the last release and fails on incompatible changes — mechanically the same guardrail as the OpenAPI diff in Contract-First with OpenAPI, applied to binary compatibility instead of HTTP shapes.
Pick gRPC when calls are internal, chatty, latency-sensitive, multi-language, and deadline-aware. Skip it for browser-facing reads (no CDN caching of POST bodies), file downloads over flaky links, and partners who just want JSON. Once clients get query-shaped flexibility instead of fixed endpoints, the trade-off set changes again — that is GraphQL Trade-offs.
Contract distribution is the operational half people forget. Generated code stays in step only if every language pulls from one registry: publish .proto files to a schema registry (Buf Schema Registry, a versioned repository, or an artifact store), version them with semver, and fail CI when a service's generated stubs lag the published schema. Without that, each team vendors its own copy and the contract layer rebuilds the monolith problem in miniature.
Security at the edge differs from REST in mechanics only: tokens and tracing arrive as gRPC metadata, the gateway validates them and forwards trusted claims downstream, and mesh traffic is typically mTLS. The gateway's job remains the uniform edge policy from any API tier, translated into the gRPC status model — UNAUTHENTICATED for a missing or expired token, PERMISSION_DENIED for a valid identity lacking scope — each carrying a message a client can act on.
Testing gets cheaper with interceptors: one middleware layer records deadlines, retries, and status codes for every call, which is where your configured retry and deadline discipline actually becomes visible. Pairing buf breaking in CI with interceptor-based metrics shows an interviewer two things at once — the contract is enforced before merge, and the runtime is observed after deploy.
One boundary worth naming: teams that want a single protocol from browser to backend often standardize on Connect, which speaks protobuf or JSON over ordinary HTTP and runs in browsers without a sidecar, while still generating typed clients from the same .proto. It trades a few gRPC-specific mechanics for one toolchain across every surface — a trade-off to state explicitly rather than a default to assume.
Quiz
1. A team deletes `string legacy_note = 3;` from a message without adding `reserved`. Months later a new field reuses number 3. What happens?
2. You change a field from `int32 amount` to `string amount` in a deployed message. What is the result for existing producers?
3. A batch import must read 50,000 records from a client and reply with one summary. Which RPC shape fits, and what is the main operational risk?
4. A browser team wants to call your internal gRPC services directly. What is the correct architectural answer?
Flashcards
Question
What is a protobuf field number, and which changes to it are forbidden?
Click to reveal answer
Answer
The field number is the field's wire identity — the tag is `(number << 3) | wire_type` — while the name only serves generated code and JSON mapping. Forbidden: renumbering a deployed field, and reusing a number or name after deletion (hence `reserved 3, 4;` and `reserved "legacy_note";`). Safe: adding fields with fresh numbers, since old readers skip unknown fields.
Question
Which type changes are wire-safe and which are wire-breaking in proto3?
Click to reveal answer
Answer
Wire-breaking: anything changing the wire type — varint to length-delimited (`int32` to `string`), scalar to `map`, packed-incompatible shapes. Wire-safe with review: widening among varint types (`int32` to `int64`), which decodes fine but truncates out-of-range values; packed to unpacked `repeated` scalars; renames (wire yes, JSON/codegen no). Switching to `optional` changes presence semantics.
Question
What do HTTP/2 and flow control give a gRPC stream that a naive socket protocol does not?
Click to reveal answer
Answer
One connection multiplexes many concurrent RPCs without HTTP/1 head-of-line blocking, and per-stream flow-control windows push backpressure all the way to the producer: a slow consumer fills the window and the sender blocks rather than buffering without limit. The catch is your own code — if the server drains streams into an unbounded queue, the memory problem simply moves past the transport.
Question
Name the four RPC shapes and one operational cost of each.
Click to reveal answer
Answer
Unary (one/one): cheapest, trivially load-balanced. Server stream (request, many responses): long-lived RPC to route, monitor, cancel. Client stream (many, one summary): partial side effects on mid-stream failure. Bidirectional (both concurrent): cancellation, deadlines, and load balancing all get harder — default to unary unless you need it.
Question
Why do browsers usually need REST or Connect instead of native gRPC, and what is the east-west/north-south split?
Click to reveal answer
Answer
Browsers expose neither HTTP/2 trailers (gRPC's status channel) nor gRPC-Web's flow-control surface, so you use gRPC-Web behind a proxy, Connect over fetch, or plain REST/JSON. Architecturally: internal east-west service calls run gRPC, while north-south public traffic is translated to REST/JSON or GraphQL at the edge (grpc-gateway, Envoy transcoding, or a BFF facade).
Revision Notes
Key Takeaways
- 1. Field numbers are permanent: never renumber, always `reserved` deletions, because decoding keys on numbers and silently mis-routes reused ones
- 2. Type changes are wire-type changes: varint to length-delimited breaks decoding, while widening varint types decodes but can truncate values
- 3. Proto3 scalars have no presence — decide unset-versus-zero per field at design time or absent money reads as zero
- 4. Default to unary RPC; streaming buys backpressure and live feeds at the cost of partial side effects, harder retries, and L4 load balancers that pin streams
- 5. Deadlines and canonical status codes are the contract: propagate `grpc-timeout`, switch on `UNAVAILABLE` versus `FAILED_PRECONDITION`, and let browsers talk REST, gRPC-Web, or Connect at the edge
Interview Tips
- • "How do you evolve a proto without breaking services?" — new field numbers only, `reserved` on every deletion, widen-not-narrow, presence decided up front, and `buf breaking` in CI against the last release
- • "What is the wire format of a protobuf field?" — tag then value, tag = (number << 3) | wire_type; derive from that one fact both unknown-field skipping and why renumbering corrupts data instead of erroring
- • "When would you choose gRPC over REST?" — internal chatty calls needing deadlines, typed multi-language clients, and streaming; concede REST for browsers, CDN-cached public reads, and partner-facing JSON
- • "How does backpressure work in a gRPC stream?" — HTTP/2 flow-control windows block the sender when the consumer stalls, then immediately add the real-world caveat: an unbounded in-process queue bypasses it entirely
Cheat Sheet
gRPC and Protobuf Contracts
Wire format: tag (number << 3 | wire_type) + value. Number = identity; name = codegen/JSON only.
Evolution rules:
| Change | Safe? |
|---|---|
| add field, new number | yes — unknown fields skipped |
delete + reserved number, "name" |
yes |
| rename, same number | wire yes / JSON + codegen no |
int32 → string |
no — varint vs length-delimited |
int32 → int64/bool |
wire yes — check value ranges |
| packed ↔ unpacked repeated | yes |
scalar → map |
no |
scalar → optional |
presence semantics change |
Presence: proto3 scalars cannot distinguish unset from zero — money and timestamps need optional or a message.
RPC shapes: unary (default) · server stream (feeds, progress) · client stream (import, partial side effects) · bidirectional (chat, market data).
Runtime mechanics:
deadline caller sets it -> grpc-timeout header -> propagate downstream
statuses NOT_FOUND / FAILED_PRECONDITION / ABORTED / UNAVAILABLE / DEADLINE_EXCEEDED
retries service config retryableStatusCodes + budget, idempotent methods only
transport HTTP/2: multiplexed connection, per-stream flow control = real backpressure
Placement: east-west internal = gRPC; north-south browser/public = REST/JSON, gRPC-Web, or Connect via gateway. L4 balancers pin all streams to one backend — use L7, client-side round_robin, or xDS.
CI: buf lint + buf breaking --against + buf generate — the binary twin of the OpenAPI lint/diff/codegen pipeline.