Stylesgrpcprotobufcodegenstreaminghttp2internal apis

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
What does a schema-enforced, binary, streaming RPC contract buy internal consumers — and what does it commit the team to when browsers, partners and evolution show up?
Consumers
Internal services in a shared build ecosystem calling each other at volume: pricing, inventory, ledger, risk — plus platform teams who own the codegen pipeline and the HTTP/2-aware infrastructure those calls run over.
The promise
Every operation, request and response is defined in a schema both ends compile against; mismatches fail at build time; payloads are compact; streaming is native; and field-number discipline lets the schema evolve without breaking deployed clients.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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).

A proto with the evolution rules built into it
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 streaming
7}
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 it
18}
19
20enum ReservationStatus {
21 RESERVATION_STATUS_UNSPECIFIED = 0; // the value old clients see for anything new
22 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).

Proto changes and what they do to deployed clients
ChangeWire-compatible?Generated-code-compatible?Rule
Add a field with a new numberYesYesThe everyday evolution path
Rename a fieldYesNoTreat as breaking for consumers of generated code
Change a field's typeNo — silent garbageNoNever; add a new field instead
Remove a fieldYes if reservedNoReserve the number and name forever
Reuse a field numberNo — silent garbageNever; this is what reserved prevents
Add an enum valueYesYesOld clients see 0 — make 0 an explicit UNSPECIFIED sentinel
Add an RPC methodYesYesAdditive
Change a method signatureNoNoNew 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.

The honest comparison
AxisREST/JSONgRPCDecided by
Browser & public friendlinessNativeProxy or transcoding requiredWho the consumers are
Type contractBy docs or OpenAPIEnforced at buildWhether both ends share a build pipeline
Per-call costText parse, HTTP/1.1 connectionsBinary, multiplexedCall volume and fan-out
CachingCDNs and gatewaysCaller-side onlyRead/write ratio and edge topology
StreamingSSE/WebSockets beside itFour native shapesWhether flows are request/response
Readability & debuggingAny proxy logNeeds decoding toolsOn-call tolerance for opaque frames
EvolutionAdditive JSON, versionsField numbers, reserved, lintersAppetite 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.

  1. 1
    Team → partners: publishes the internal gRPC service as the public API; partners spend a week finding a client library and cannot curl anything.
  2. 2
    Engineer → proto: changes quantity from int32 to string in place; old clients decode garbage and reserve nonsense quantities.
  3. 3
    Engineer → enum: adds SUSPENDED = 3 without a zero sentinel; a client built last quarter reads the default and treats suspended accounts as active.
  4. 4
    Ops → load balancer: an L4 balancer pins every HTTP/2 connection to one instance; the multiplexed traffic hot-spots a single pod.
  5. 5
    On-call → logs: a production incident shows binary frames in the proxy; nobody can read the payloads without a decode step.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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.

Misconceptions

Claim
“gRPC is always faster, so it should replace REST.”
Reality
Per call, yes; per system, not necessarily. Public read traffic benefits more from CDN caching gRPC cannot use, and the consumer environment — browsers, partners — decides before performance does.
Claim
“Protobuf makes evolution automatic.”
Reality
It makes evolution *rule-governed*. Adding fields is safe; retyping or reusing numbers corrupts silently. The rules must be enforced by a linter, not remembered.
Claim
“Streaming is why you pick gRPC.”
Reality
Native streaming is a real advantage for internal flows, but a public dashboard is usually better served by SSE over plain HTTP. Pick gRPC for typed internal traffic and take streaming as a bonus.

Apply it