gRPC: Schema, Codegen and Streams
gRPC is RPC with a schema language (protobuf), generated clients, binary framing and four call shapes including streaming, on HTTP/2. It buys enforced contracts and efficient internal traffic; it costs browser friendliness, readability, and a proto discipline that decides whether evolution is safe.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The contract is a compiled artifact
gRPC starts from a .proto file: services with operations, messages with numbered fields. From that one file every language generates clients and server stubs, so the contract is not documentation somebody keeps in sync — it is the thing both ends compile (Schema-First vs Code-First). A caller that sends a wrong type does not get a 400 in production; it fails to build. That is the largest single advantage over hand-maintained REST clients, and it is why gRPC's natural home is a fleet of services owned by teams who share a build pipeline.
Messages are serialized as compact binary keyed by field number, not name. On the wire a user_id is a tag and a varint, not "user_id":, which is why payloads are smaller and parsing is cheaper than JSON — measurable on hot internal paths, irrelevant on a partner API that makes ten calls a minute (Payload Size: 20KB, 200KB, 5MB). Transport is HTTP/2: many concurrent calls multiplexed on one connection, header compression, and framing that makes streaming a first-class citizen rather than a long-lived hack (HTTP/2: Streams on One Connection).
Four call shapes fall out of that transport: unary (one request, one response), server streaming (one request, many responses — a large result set or progress events), client streaming (many requests, one response — uploads, telemetry), and bidirectional streaming (both — a chat or a live sync). REST reaches the same shapes only by adding SSE or WebSockets beside it (Streaming APIs: Partial Data as a Contract).
1syntax = "proto3";2 3service InventoryService {4 rpc GetStockLevel (GetStockLevelRequest) returns (StockLevel);5 rpc ReserveInventory (ReserveInventoryRequest) returns (Reservation);6 rpc WatchStockLevels (WatchRequest) returns (stream StockLevel); // server streaming7}8 9message Reservation {10 string id = 1;11 string sku = 2;12 int32 quantity = 3;13 // 4 was `warehouse` (string); removed in v2026.3 — never reuse the number.14 reserved 4;15 reserved "warehouse";16 ReservationStatus status = 5;17 google.protobuf.Timestamp expires_at = 6; // added later: old clients ignore it18}19 20enum ReservationStatus {21 RESERVATION_STATUS_UNSPECIFIED = 0; // the value old clients see for anything new22 RESERVATION_STATUS_HELD = 1;23 RESERVATION_STATUS_RELEASED = 2;24}Evolution lives in the field numbers
Protobuf's compatibility model is precise and unforgiving. Adding a field with a new number is safe: old clients skip unknown tags, new clients see defaults from old servers. Renaming a field is safe on the wire (names are not serialized) and breaking in generated code. Changing a field's type or reusing a number is silently catastrophic — the old client decodes the new bytes as the old type and gets garbage, not an error. Removing a field requires reserved so the number can never be reused (Removing Fields Without Removing Consumers).
Enums deserve their own warning: a new enum value arriving at an old client decodes to the zero value in proto3, which is why the convention of an _UNSPECIFIED = 0 sentinel exists — without it, "suspended" silently becomes "active" in a client that has never heard of suspension (Enum Evolution: The New Value That Broke Old Clients). None of this is harder than JSON evolution; it is just more explicit, and the explicitness is the point — the rules are checkable by a linter in CI, which JSON contracts rarely get (Backward Compatibility: The Real Rules).
| Change | Wire-compatible? | Generated-code-compatible? | Rule |
|---|---|---|---|
| Add a field with a new number | Yes | Yes | The everyday evolution path |
| Rename a field | Yes | No | Treat as breaking for consumers of generated code |
| Change a field's type | No — silent garbage | No | Never; add a new field instead |
| Remove a field | Yes if reserved | No | Reserve the number and name forever |
| Reuse a field number | No — silent garbage | — | Never; this is what reserved prevents |
| Add an enum value | Yes | Yes | Old clients see 0 — make 0 an explicit UNSPECIFIED sentinel |
| Add an RPC method | Yes | Yes | Additive |
| Change a method signature | No | No | New method, deprecate the old |
REST vs gRPC without a winner
The comparison is decided by the consumer environment, which is why Which API Style Should I Use? asks who calls it first. Browsers cannot speak gRPC natively; gRPC-Web and transcoding gateways bridge the gap at the cost of another component. Third-party developers want curl, readable JSON and docs, and get a binary protocol requiring codegen. Internal fleets want compile-time contracts, small payloads and streaming, and get exactly that. Debuggability shifts too: a REST exchange is readable in any proxy log; a gRPC frame needs tooling to decode — a real cost during incidents.
Performance claims need qualification. gRPC is cheaper per call — less serialization CPU, fewer bytes, multiplexed connections — which matters when a request fans out to twenty internal calls. For a public API dominated by round-trip latency, caching and payload design, REST with a CDN often delivers lower end-to-end latency because gRPC responses cannot be cached by anything between client and server (Caching as a Contract Clause). "gRPC is faster" is true of the call and not necessarily of the system.
| Axis | REST/JSON | gRPC | Decided by |
|---|---|---|---|
| Browser & public friendliness | Native | Proxy or transcoding required | Who the consumers are |
| Type contract | By docs or OpenAPI | Enforced at build | Whether both ends share a build pipeline |
| Per-call cost | Text parse, HTTP/1.1 connections | Binary, multiplexed | Call volume and fan-out |
| Caching | CDNs and gateways | Caller-side only | Read/write ratio and edge topology |
| Streaming | SSE/WebSockets beside it | Four native shapes | Whether flows are request/response |
| Readability & debugging | Any proxy log | Needs decoding tools | On-call tolerance for opaque frames |
| Evolution | Additive JSON, versions | Field numbers, reserved, linters | Appetite for enforced rules |
Key points
- gRPC makes the contract a compiled artifact: schema-first protos, generated clients, mismatches caught at build time.
- Binary framing on HTTP/2 makes calls cheaper and streaming native; neither matters until call volume or flow shape demands it.
- Evolution is governed by field numbers: add freely, reserve on removal, never retype or reuse, give enums a zero sentinel.
- Browsers and partners need a proxy or transcoding; readability during incidents costs tooling.
- REST vs gRPC is decided by consumer environment and system-level performance, not by per-call benchmarks.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → partners: publishes the internal gRPC service as the public API; partners spend a week finding a client library and cannot curl anything.
- 2Engineer → proto: changes
quantityfromint32tostringin place; old clients decode garbage and reserve nonsense quantities. - 3Engineer → enum: adds
SUSPENDED = 3without a zero sentinel; a client built last quarter reads the default and treats suspended accounts as active. - 4Ops → load balancer: an L4 balancer pins every HTTP/2 connection to one instance; the multiplexed traffic hot-spots a single pod.
- 5On-call → logs: a production incident shows binary frames in the proxy; nobody can read the payloads without a decode step.
- Silent data corruption across the fleet from a retyped or reused field number — no error, wrong values.
- New enum values collapse to a wrong known state in older clients.
- External consumers locked out or forced onto bespoke clients; support load rises.
- Uneven load and stalled deploys when infrastructure is not HTTP/2-aware.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Use gRPC for internal, typed, high-volume or streaming traffic; front external consumers with a REST/JSON facade via transcoding.
- • Enforce proto evolution rules in CI: a breaking-change linter, mandatory `reserved` on removal, zero-value sentinels for enums.
- • Declare idempotency, retry guidance and the error set per method in the proto comments and error details.
- • Run HTTP/2-aware load balancing (L7 or client-side) so multiplexed connections spread.
- • Ship decode tooling and structured logging so on-call can read payloads during incidents.
- • Per-method latency, error code distribution and message sizes from the gRPC interceptors — per-method metrics come free and should be on dashboards.
- • Load skew across instances under HTTP/2 indicates connection-level balancing.
- • CI failures from the proto linter are the compatibility signal working; their absence means nothing is checking.
- • Partner support tickets about client libraries indicate gRPC exposed at the wrong edge.
- • Add fields and methods freely; deprecate methods by adding replacements and tracking caller versions before removal ([[consumer-driven-evolution]]).
- • Package versioning (`inventory.v1`, `inventory.v2`) handles the rare genuinely breaking change with dual support.
- • A transcoding gateway lets the same protos serve REST/JSON to browsers and partners without a second implementation.
- • Codegen and a shared build pipeline are prerequisites; teams without them get the costs and little of the benefit.
- • Binary payloads trade readability for efficiency; every debugging workflow needs a decode step.
- • Enforced evolution rules are strict enough that a careless change corrupts silently rather than failing loudly — the linter is not optional.