S058 · gRPC & Protobuf — When RPC Wins
Binary framing + strict schemas + streaming = the internal-service default at Google, Netflix, Uber, Cloudflare. What Protobuf actually is, how gRPC uses HTTP/2, when RPC is the right shape, and a working polyglot client-server in Python.
🎯 Define a Protobuf schema, generate client + server code, run unary and streaming RPCs, and articulate three concrete situations where gRPC beats REST for internal traffic.
Why this session exists
Inside every large tech company, the service-to-service protocol is almost never REST — it's gRPC (Google), Thrift (Meta), or another binary RPC. The reasons are unglamorous but decisive: strict schemas that break the build when contracts drift, ~5-10× smaller payloads over the wire, native streaming, and code generation across every language your microservices are written in. Learning gRPC is what separates ‘I write APIs’ from ‘I design the platform APIs everyone else consumes’.
- Write a `.proto` file with messages, services, and streaming methods.
- Generate Python client + server stubs and run a unary + a server-streaming RPC.
- Explain Protobuf's wire format at a high level (tags, varints, wire types).
- Contrast gRPC with REST and GraphQL on three axes: schema strictness, transport, streaming.
- Pick the right RPC shape (unary, server-stream, client-stream, bidi) for a given problem.
Prerequisites
- S055 · HTTP fundamentals — HTTP/2 in particular.
- S056 · REST API design — the alternative you'll compare against.
- S018 · Python packaging / venv — because we'll run codegen.
(a) Intuition · 5 min
A letter is human-readable. Anyone can open it, read it, understand it, forward it. That flexibility is its strength — and its cost. Envelopes are bulky, addresses are inconsistent, and the postal system has to guess a lot.
A shipping manifest is a rigid, tabular document. Column 3 always means SKU, column 7 always means weight. Machines process thousands per second. Humans hate reading them raw, but scanners love them and mistakes are almost impossible.
REST + JSON is the letter — human-readable, forgiving, but slow to parse and easy to drift. Great when you don't control the reader.
gRPC + Protobuf is the manifest — binary, strictly typed, generated code on both ends. Ten times smaller on the wire, orders of magnitude faster to parse, and impossible to send a request the server can't parse. Perfect when you own both ends of the pipe.
The four RPC shapes gRPC gives you
- Unary — client sends one request, server sends one response. The REST analogue.
- Server-streaming — one request, many responses. Perfect for tailing logs, subscription feeds, download progress.
- Client-streaming — many requests, one response. Ideal for large uploads, telemetry batches, sensor streams.
- Bidirectional streaming — many both ways over one connection. Chat, live collaboration, gaming.
How gRPC got here
- 2001Google internal ‘Stubby’ RPCGoogle's original binary RPC. Fast, typed, but proprietary. Every service inside Google talks it.
- 2008Protobuf 2 open-sourcedThe serialisation layer without the RPC framework. Adopted at LinkedIn, Twitter, Netflix quickly.
- 2015gRPC 1.0 releasedStubby, cleaned up and open-sourced. Sits on HTTP/2. Immediately adopted by Kubernetes, Envoy, CoreDNS.
- 2018Google ships gRPC-WebBridges to browsers via a proxy. Web frontends can finally use gRPC without WebSockets.
- 2022Connect / gRPC over HTTP/1.1 + JSONBuf's Connect protocol adds REST-friendly transport for the same schemas. Best of both worlds for polyglot orgs.
(b) Visual walkthrough · 15 min
The build pipeline
The .proto is checked into a shared repo (often called a ‘schema registry’ or ‘proto repo’). All services regenerate on every build — the schema is the contract, and it's enforced by the compiler.
Anatomy of a message
What actually goes on the wire
gRPC on HTTP/2
One TCP+TLS connection, kept warm. Every RPC is a stream on that connection.
`:path = /myservice.MyService/GetUser`, `content-type: application/grpc+proto`, custom metadata as headers.
The serialized Protobuf request body, prefixed with 1 byte (compression flag) + 4 bytes (length).
Same framing in reverse. For streams, DATA frames continue until END_STREAM.
gRPC-Status + gRPC-Message in HTTP trailers. Errors are structured, not free-form.
When to reach for gRPC vs REST vs GraphQL
Strict schema, binary, streaming
- 5-10× smaller payloads than JSON
- Codegen for every mainstream language
- Native streaming (server/client/bidi)
- Poor browser story without gRPC-Web/Connect
- Debugging needs specialised tools (grpcurl)
Human-readable, cacheable, universal
- Every dev knows it, every language supports it
- HTTP cache + CDN work out of the box
- curl-friendly for debugging
- Larger payloads (JSON overhead)
- No streaming (SSE / WebSocket bolt-ons)
Client-shaped queries over a graph
- One request replaces N REST calls for UI screens
- Fields chosen by client
- Schema + tooling comparable to gRPC
- Complex authz + cost analysis needed
- Not a good fit for internal service-to-service
"gRPC is fast because Protobuf is a binary format. Binary beats JSON, that's the whole story."
Encoding size is the smaller half. The larger wins are HTTP/2 multiplexing (many concurrent RPCs on one connection with no head-of-line blocking at the HTTP layer), no per-field key strings on the wire, no runtime type inference during parsing, and generated code that deserializes directly into structs without allocating a dictionary per message. On small payloads over a fast internal network, the difference is dominated by parsing and connection behaviour, not by bytes transmitted.
The myth is sticky because "binary is smaller" is true, measurable, and easy to demo, so it explains the result you observed. It misleads because it predicts gRPC's advantage should vanish on small messages — and it doesn't. It also leads people to reach for gRPC purely for payload size when compression on JSON would have closed most of that particular gap.
Look at what is actually on the wire and notice what is absent:
# JSON: field names shipped with every message
{"user_id": 12345, "is_active": true} # ~36 bytes
# Protobuf: field NUMBER + wire type, no names
08 b9 60 10 01 # 5 bytes
# ^tag 1, varint ^tag 2, varintThe field names exist only in the .proto, shared out of band at compile time. That is the real trick: the schema is not transmitted, so the message carries no self-description at all — which is also exactly why you cannot read a Protobuf message without its schema.
Why must you never reuse a Protobuf field number, even for a field you deleted years ago? This looks like excessive caution — it is a hard correctness requirement.
- 1A Protobuf message on the wire is a sequence of (field number, wire type, value) triples. The field name is never transmitted.forced by · omitting names is precisely where the size and parse-speed advantage comes from
- 2Therefore the receiver identifies a field solely by its number, resolved against whatever
.protoversion it was compiled with.forced by · the message carries no self-description, so interpretation depends entirely on the reader's local schema - 3In any real system, senders and receivers run different schema versions simultaneously — deploys are not atomic, and messages persist in queues, logs and databases.forced by · rolling deploys and durable storage guarantee old encodings meet new readers and vice versa
- 4So if field 7 was
user_id(int64) and is later reused ascreated_at(int64), an old message decoded by new code yields a user ID silently interpreted as a timestamp.forced by · the wire types match, so nothing fails — the bytes parse perfectly into the wrong meaning - 5No error is raised at any layer, so the corruption is silent and can persist in derived data indefinitely.forced by · type checking happens against the local schema, and the local schema says field 7 is a timestamp
Therefore field numbers are permanent identity, not slots. The reserved keyword exists to make the compiler enforce this, and it should be used every single time a field is removed.
And note what this predicts: the same argument applies to enum values. Reusing an enum number silently changes the meaning of persisted data, which is why reserved works on enums too — and why every enum should have an explicit UNKNOWN = 0, since unset fields decode as zero and you need "unset" to be distinguishable from a real value rather than colliding with your first meaningful case.
The .proto file is a shared contract compiled into client and server as native types. The network carries only numbered values; both ends already know what the numbers mean. Nothing is negotiated at runtime, nothing is inferred, nothing is self-describing.
Because the transport is HTTP/2, a single connection carries many concurrent streams, and a stream can flow in either direction for as long as it likes — which is why streaming RPCs exist at all rather than being bolted on.
- Four call shapes, and choosing correctly is a design decision: unary (one request, one response), server streaming (subscriptions, large result sets), client streaming (uploads, telemetry ingestion), bidirectional (chat, live sync). Reaching for unary when the data is a stream forces polling.
- Schema evolution rules are mechanical: adding an optional field is safe, removing a field requires
reserved, changing a field's type or number is a break. Following these gives genuinely independent deploys; violating one produces silent corruption rather than a clean failure. - Unset and default are indistinguishable in proto3 scalars — a missing int is 0, a missing bool is false. If you need to know whether a value was set, use an explicit
optionalfield or a wrapper type. Countless bugs live in this gap. - The binary wire format is unreadable without the schema. That is a real operational cost:
curl, browser devtools, log inspection and proxies all stop working, so you needgrpcurl, server reflection, and deliberate observability tooling.
Fire this model when you see: a field that "sometimes comes back as zero" · a client and server disagreeing after a partial rollout · someone about to reuse a deleted field number · polling a service every second for updates · an internal API where JSON parsing shows up in a CPU profile.
For a new internal service-to-service API: gRPC with Protobuf, or JSON over HTTP?
curl and browser tools. Browsers cannot speak gRPC directly and need a gRPC-Web proxy. Load balancers must be HTTP/2-aware or all streams pin to one backend, because a single long-lived connection carries everything..proto, so external consumers and browsers get plain HTTP while internal services keep the benefits.Use gRPC where the caller is a service you control and the call rate or type-safety pressure is real. Use JSON at any boundary crossing an organisational line, because the cost of a partner integrating with your .proto is far higher than your serialization savings.
The most commonly underestimated cost is load balancing. gRPC multiplexes over one long-lived HTTP/2 connection, so a naive L4 load balancer pins every request from that client to a single backend and your traffic distribution quietly becomes uneven. Confirm your infrastructure does HTTP/2-aware balancing before adopting gRPC broadly — this is discovered during an incident far more often than during design.
(c) Hands-on · 25 min
We'll build a small ‘echo’ service with unary + server-streaming RPCs, generate Python stubs, and see the wire messages fly.
1. Define the schema
Save as demo.proto:
syntax = "proto3";
package demo;
// Request/response messages
message SayHelloRequest {
string name = 1;
int32 shouts = 2; // how many replies to stream back
}
message SayHelloResponse {
string greeting = 1;
int32 sequence = 2;
}
// Service definition
service Greeter {
// Unary RPC: one request, one response
rpc SayHello (SayHelloRequest) returns (SayHelloResponse);
// Server-streaming: one request, many responses
rpc SayHelloStream (SayHelloRequest) returns (stream SayHelloResponse);
}2. Generate stubs
python -m pip install grpcio grpcio-tools
python -m grpc_tools.protoc \
--proto_path=. \
--python_out=. \
--grpc_python_out=. \
demo.protoThis produces demo_pb2.py (message classes) and demo_pb2_grpc.py (service stubs). You do not edit generated files.
3. Server
Save as server.py:
"""server.py — implement Greeter over the generated interface."""
import time
from concurrent import futures
import grpc
import demo_pb2
import demo_pb2_grpc
class GreeterServicer(demo_pb2_grpc.GreeterServicer):
def SayHello(self, request, context):
# unary: return one response
return demo_pb2.SayHelloResponse(
greeting=f"Hello, {request.name}!",
sequence=0,
)
def SayHelloStream(self, request, context):
# server-streaming: yield multiple responses on the same call
for i in range(max(1, request.shouts)):
yield demo_pb2.SayHelloResponse(
greeting=f"Hello, {request.name}!",
sequence=i,
)
time.sleep(0.2)
def serve() -> None:
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
demo_pb2_grpc.add_GreeterServicer_to_server(GreeterServicer(), server)
server.add_insecure_port("127.0.0.1:50051")
server.start()
print("gRPC server listening on 127.0.0.1:50051")
server.wait_for_termination()
if __name__ == "__main__":
serve()4. Client
Save as client.py:
"""client.py — call both RPCs, print responses + wire sizes."""
import grpc
import demo_pb2
import demo_pb2_grpc
def main() -> None:
with grpc.insecure_channel("127.0.0.1:50051") as channel:
stub = demo_pb2_grpc.GreeterStub(channel)
# Unary
req = demo_pb2.SayHelloRequest(name="Dinesh", shouts=1)
print(f"[wire] request serialized bytes: {len(req.SerializeToString())}")
resp = stub.SayHello(req)
print(f"[unary] {resp.greeting} (seq={resp.sequence})")
print(f"[wire] response serialized bytes: {len(resp.SerializeToString())}")
# Server-streaming
req = demo_pb2.SayHelloRequest(name="Dinesh", shouts=3)
print("\n[stream] server-streaming responses:")
for msg in stub.SayHelloStream(req):
print(f" #{msg.sequence}: {msg.greeting}")
if __name__ == "__main__":
main()5. Run it
# terminal 1
python server.py
# terminal 2
python client.pyExpected output:
[wire] request serialized bytes: 10
[unary] Hello, Dinesh! (seq=0)
[wire] response serialized bytes: 17
[stream] server-streaming responses:
#0: Hello, Dinesh!
#1: Hello, Dinesh!
#2: Hello, Dinesh!
10 bytes for the request — compare to {"name":"Dinesh","shouts":1} in JSON (28 bytes). Nearly 3× smaller for a trivial payload. Real business messages compress even more.
Anatomy of the setup
What each artefact does
Edit demo.proto and change int32 shouts = 2; to int32 shouts = 5;. Rebuild the server only. Run the client (still on tag 2). The server will read shouts = 0 — silent data loss.
Then also change the type to string shouts = 5; on the server only, restart. The client sends an int; the server reads it as a mangled string. Parsing may fail or produce garbage.
The lesson: tags are the ABI. Reserve retired tags with reserved 2; and never repurpose them.
(d) Production reality · 15 min
Google's internal RPC system (Stubby) was proprietary and enormous — every service, every language, every team ran on it. External adoption was zero because the code was private.
The rest of the industry had settled on REST + JSON, which Google engineers found painful compared to what they had internally: schema-first codegen, streaming, and 5-10× smaller wire payloads.
The industry standard patterns:
- One central proto repository per org. Every service consumes generated packages from it (Buf, Bazel, or Nx pattern).
- Buf CLI (or similar) runs breaking-change detection on every PR. Removing a field, renumbering a tag, or narrowing a type fails CI.
- Versioned packages:
demo.v1,demo.v2. Breaking changes mean a new package, not a mutated one.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Move on when you can teach these without notes:
- Why do big companies pick gRPC over REST internally?
- What is a Protobuf tag and why must you never renumber one?
- When would you specifically want a bidirectional stream instead of two unary RPCs?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.