gRPC
gRPC turns a .proto file into typed clients and servers that talk compact protobuf over HTTP/2. Read a contract and its generated stubs, compare wire sizes, step through all four call shapes, and decide when gRPC beats REST or GraphQL.
🎯 What you'll learn
- Protocol Buffers as the contract
- Codegen and the binary wire format over HTTP/2
- Unary, client-, server- and bidirectional streaming
- Choosing between gRPC, REST and GraphQL
📚 Before you start
This topic assumes the REST model from earlier in this group and the GraphQL model from 12-graphql. gRPC is a third distinct approach: instead of REST's resource-oriented URLs or GraphQL's client-specified query language, gRPC exposes strongly-typed remote procedure calls — the client calls what looks like a local function, and the framework handles serialization and network transport. This part covers Protocol Buffers, the strict contract they enforce, and simple request/response (unary) calls. Part 2 covers streaming modes and when to actually choose gRPC over REST/GraphQL.
1Protocol Buffers: the Contract, in a .proto File
Every gRPC service is defined in a .proto file — both the data shapes AND the callable methods, compiled into strongly-typed client/server code for whichever language you target:
syntax = "proto3";
package orders;
message Order {
string id = 1;
string status = 2;
int64 total_cents = 3;
}
message GetOrderRequest {
string id = 1;
}
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
}The numbers after each field (= 1, = 2, = 3) are field tags — they identify the field on the wire, not the field's position or name. This is central to Protocol Buffers' wire format (see Under the Hood-style detail in section 3) and to how the format stays backward-compatible as a schema evolves.
2Codegen: Client and Server Stubs From One Source of Truth
A .proto file is compiled (via protoc or a language-specific plugin) into real client and server code — this is the single most distinctive difference from both REST and GraphQL, where the schema documents the contract but doesn't directly generate the calling code:
// Generated client stub (conceptually — real codegen output is more verbose)
const client = new OrderServiceClient("orders.internal:50051");
const order: Order = await client.getOrder({ id: "482" }); // looks like calling a local async function// Generated server-side interface the implementation must satisfy
class OrderServiceImpl implements OrderServiceServer {
async getOrder(request: GetOrderRequest): Promise<Order> {
return db.orders.findById(request.id);
}
}Both sides are generated from the same .proto file, which means a mismatch between what the client sends and what the server expects is caught by the type system at compile time, in both languages — not discovered at runtime the way a REST client sending an unexpected JSON shape would be.
3Binary Wire Format Over HTTP/2
Unlike REST/GraphQL's human-readable JSON over HTTP/1.1 (or HTTP/2, but typically still JSON), gRPC serializes messages into a compact binary format and always runs over HTTP/2:
JSON (REST/GraphQL): {"id":"482","status":"confirmed","total_cents":5000} -- ~50 bytes, human-readable
Protobuf (gRPC): 0x0a 0x03 "482" 0x12 0x09 "confirmed" 0x18 0x88 0x27 -- ~20 bytes, NOT human-readableEach field is encoded as (tag, wire-type, value) — the tag from section 1 identifying which field it is on the wire, without ever transmitting the field's name ("status" as a string) the way JSON does. This is smaller and faster to parse than JSON, at the direct cost of no longer being readable with curl and your eyes — inspecting gRPC traffic requires a protobuf-aware tool (like grpcurl) that knows the .proto schema to decode it.
HTTP/2 (vs. REST's typical HTTP/1.1) brings multiplexed streams over one connection (many concurrent calls without head-of-line blocking) and is a hard requirement for gRPC's streaming modes covered in Part 2 — HTTP/1.1 has no mechanism for a long-lived bidirectional stream the way HTTP/2 does.
4Unary Calls: the Simple Case
A unary RPC is the simple one-request-one-response case — the GetOrder example above. It behaves like a REST GET or a GraphQL query: send a request, await exactly one response:
try {
const order = await client.getOrder({ id: "482" });
} catch (err) {
// gRPC errors carry a STATUS CODE from a fixed enum (OK, NOT_FOUND, INVALID_ARGUMENT,
// PERMISSION_DENIED, UNAVAILABLE, ...) plus an optional message -- conceptually
// parallel to HTTP status codes (backend/api-design/09-error_response_design),
// but gRPC's set is smaller, fixed, and NOT the same numbering as HTTP status codes.
}Part 2 covers the three other call shapes gRPC supports beyond unary — client streaming, server streaming, and bidirectional streaming — none of which have a clean REST or plain-HTTP-JSON equivalent.
⚠️ Common Pitfalls
- Reusing a field tag number for a different field after a schema change. Because the wire format identifies fields by tag number, not name (section 3), reassigning tag
2fromstatusto something else makes old and new clients silently misinterpret each other's data — tags, once assigned, are never reused. - Trying to read gRPC traffic with
curlor a browser devtools network tab. The binary wire format (section 3) isn't human-readable; use a protobuf-aware tool. - Assuming gRPC error codes map 1:1 to HTTP status codes. They're a separate, gRPC-specific enum —
NOT_FOUNDisn't literally the number404on the wire. - Hand-writing client/server code instead of using codegen. Defeats the entire point of section 2's shared-contract guarantee — a hand-written client can drift from the
.protofile exactly the way a REST client can drift from an OpenAPI spec.
📋 Quick Reference
.proto file -- defines messages + services, compiled into client/server stubs
Field tags (=1,=2) -- identify fields ON THE WIRE, not by name -- never reuse after removal
Codegen -- BOTH client and server generated from the SAME .proto -- compile-time contract
Wire format -- binary, NOT human-readable -- smaller/faster than JSON, needs protobuf-aware tools
Transport -- always HTTP/2 -- required for Part 2's streaming modes
Unary call -- one request, one response -- like a REST GET or GraphQL query
Errors -- fixed gRPC status-code enum, separate from HTTP status codesPart 1 covered .proto contracts, codegen, the binary wire format over HTTP/2, and unary calls. This part covers the three streaming call shapes HTTP/2's multiplexed, long-lived connections make possible, and closes with a direct tradeoff comparison against REST and GraphQL.
1Four Call Shapes, Declared in the .proto Itself
The stream keyword on either side of an rpc declaration marks which direction(s) are streamed:
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order); // unary (Part 1)
rpc ImportOrders(stream OrderInput) returns (ImportSummary); // client streaming
rpc WatchOrderStatus(WatchRequest) returns (stream OrderStatusUpdate); // server streaming
rpc SyncOrders(stream OrderInput) returns (stream SyncResult); // bidirectional streaming
}2Client Streaming: Many Requests, One Response
The client sends a sequence of messages over one call, then the server responds once after the client signals it's done sending:
const call = client.importOrders();
for (const order of largeOrderBatch) {
call.write(order); // send one at a time, doesn't wait for a response per item
}
const summary = await call.end(); // signal "done sending", await the single final responseUseful for uploading a large dataset (bulk import) without buffering the entire payload into one giant request message first — each item streams as it's ready.
3Server Streaming: One Request, Many Responses
The inverse — one request, then the server pushes a sequence of messages over time:
const stream = client.watchOrderStatus({ orderId: "482" });
stream.on("data", (update: OrderStatusUpdate) => {
console.log("status changed to", update.status); // fires every time the server has a new update
});Conceptually similar to GraphQL's Subscription (12-graphql Part 2, section 4) or a Server-Sent Events endpoint — the server keeps pushing until the call ends, without the client polling.
4Bidirectional Streaming: Both Sides, Independently, Concurrently
Both client and server send messages on the same call, and — critically — not in lockstep: each side reads and writes independently, at its own pace, over the same long-lived HTTP/2 stream:
const call = client.syncOrders();
call.write(localChange1); // client can keep writing...
call.on("data", (result: SyncResult) => { /* ...while also receiving server pushes, interleaved */ });
call.write(localChange2);
call.end(); // signal done sending; the server may still be sending its own remaining responsesThis is the shape with no clean REST or plain-HTTP equivalent at all — REST's request/response model has no notion of two independent, concurrent, long-lived message streams over one call. It requires HTTP/2's multiplexing (Part 1, section 3) as a hard prerequisite.
5gRPC vs. REST vs. GraphQL — Choosing Deliberately
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Contract | OpenAPI (documentation, can drift — 11-api_documentation) | Schema (enforced live) | .proto (compile-time, codegen both sides) |
| Format | JSON, human-readable | JSON, human-readable | Binary, not human-readable |
| Best fit | Public APIs, broad client compatibility, browser-friendly | Client-driven UIs with varied/nested data needs | Internal service-to-service calls, performance-sensitive paths |
| Streaming | Not native (polling, SSE, or a separate WebSocket) | Subscriptions (own transport) | Native, 4 call shapes, one framework |
| Browser support | Native (fetch) | Native (fetch, plain JSON over HTTP) | Requires grpc-web proxy — browsers can't speak raw gRPC/HTTP2-trailers directly |
| Debuggability | curl, browser devtools, trivial | curl, GraphiQL/Playground, trivial | Needs protobuf-aware tooling (grpcurl) |
The practical rule of thumb this table implies: gRPC's performance and strict-contract wins matter most for internal, service-to-service traffic (microservice-to-microservice calls inside distributed-systems's architecture, where both ends are services you control and can regenerate stubs for on every deploy) — its binary format and lack of native browser support make it a poor fit as the public, browser-facing API surface, where REST or GraphQL's human-readable, fetch-native properties matter more than raw throughput.
⚠️ Common Pitfalls
- Choosing gRPC for a public, browser-facing API "because it's faster." Section 5's browser-support row is the practical blocker — you'd need a
grpc-webproxy layer just to reach it from a browser, adding complexity the performance win rarely justifies for public traffic. - Assuming bidirectional streaming means request-response pairing. Section 4's "not in lockstep" point is easy to miss — messages on each side are independent, not a 1:1 request/reply sequence like a chat turn-by-turn model might suggest.
- Forgetting client-streaming calls need an explicit "done sending" signal. The server-side handler for
ImportOrdersdoesn't know the client is finished until.end()/equivalent is called — omitting it leaves the call hanging indefinitely. - Using gRPC where a single unary call already breaks a large payload into hundreds of RPCs instead of one client-streaming call. If you find yourself calling a unary RPC in a tight loop for bulk data, that's exactly the shape client streaming (section 2) exists to replace.
📋 Quick Reference
Unary -- 1 request, 1 response (Part 1)
Client streaming -- many requests, 1 response (bulk upload)
Server streaming -- 1 request, many responses (live updates, like a subscription)
Bidi streaming -- both sides stream independently, NOT in lockstep -- needs HTTP/2
Choose gRPC for: internal service-to-service, performance-sensitive, streaming needs
Choose REST/GraphQL for: public/browser-facing APIs -- native fetch support, human-readable, debuggable🧠 Key takeaways
- A .proto file defines services and messages; field numbers are the wire identity.
- Codegen gives typed clients/servers in many languages; protobuf is compact binary over HTTP/2.
- Four shapes: unary, server streaming, client streaming, bidirectional; always set deadlines.
- Great for internal service-to-service calls; browsers need gRPC-Web/Connect; REST stays best for public APIs.
🎤 Interview Q&A
Q1What is gRPC and why use it?core▾
A high-performance RPC framework using Protocol Buffers for schemas and binary serialization over HTTP/2. It gives strongly typed contracts with generated clients in many languages, low latency, streaming, and deadlines, which makes it ideal for internal service-to-service calls.
- Downsides? No native browser support (needs gRPC-Web), harder to debug by hand, L7 load balancing required.
Q2What streaming modes does gRPC support?core▾
Unary (request/response), server streaming, client streaming, and bidirectional streaming, all over a single HTTP/2 connection.
- Use case for bidirectional streaming? Chat, real-time telemetry, or collaborative updates.
Q3How do you evolve Protobuf schemas safely?core▾
Add new fields with new field numbers; never change or reuse field numbers or types; mark removed fields as reserved; treat all fields as optional; and use buf breaking in CI to detect breaking changes.
- Why do field numbers matter? The wire format uses numbers, not names.
Q4How do deadlines and cancellation work in gRPC?senior▾
Clients set a deadline; it's propagated to servers (and downstream calls), which can check context cancellation and stop work. This prevents wasted work after the caller has given up.
- Default deadline? None, so always set one.
Q5How do you load-balance gRPC?core▾
Because HTTP/2 multiplexes requests over long-lived connections, L4 balancing pins clients to one backend. Use L7 proxies (Envoy, service mesh) or client-side load balancing with name resolution (xDS, DNS).
- Symptom of L4 balancing? New pods receive no traffic after scaling.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.