Engineering Notes
Home/Backend/API Design/gRPC
Backend · API Design

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.

⚙️ Backend● Intermediate⏱ 9 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 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

Part 1Protocol Buffers, Contracts, and Unary Calls

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:

protobuf
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:

ts
// 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
ts
// 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.

A .proto service definition is compiled into client and server stubs that exchange binary protobuf frames over HTTP/2 streams in unary, server-streaming, client-streaming or bidirectional shapes
A .proto service definition is compiled into client and server stubs that exchange binary protobuf frames over HTTP/2 streams in unary, server-streaming, client-streaming or bidirectional shapes

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:

text
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-readable

Each 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:

ts
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

  1. 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 2 from status to something else makes old and new clients silently misinterpret each other's data — tags, once assigned, are never reused.
  2. Trying to read gRPC traffic with curl or a browser devtools network tab. The binary wire format (section 3) isn't human-readable; use a protobuf-aware tool.
  3. Assuming gRPC error codes map 1:1 to HTTP status codes. They're a separate, gRPC-specific enum — NOT_FOUND isn't literally the number 404 on the wire.
  4. 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 .proto file exactly the way a REST client can drift from an OpenAPI spec.

📋 Quick Reference

text
.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 codes
Part 2Streaming Modes and When to Actually Choose It

Part 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:

protobuf
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:

ts
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 response

Useful 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:

ts
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:

ts
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 responses

This 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

RESTGraphQLgRPC
ContractOpenAPI (documentation, can drift — 11-api_documentation)Schema (enforced live).proto (compile-time, codegen both sides)
FormatJSON, human-readableJSON, human-readableBinary, not human-readable
Best fitPublic APIs, broad client compatibility, browser-friendlyClient-driven UIs with varied/nested data needsInternal service-to-service calls, performance-sensitive paths
StreamingNot native (polling, SSE, or a separate WebSocket)Subscriptions (own transport)Native, 4 call shapes, one framework
Browser supportNative (fetch)Native (fetch, plain JSON over HTTP)Requires grpc-web proxy — browsers can't speak raw gRPC/HTTP2-trailers directly
Debuggabilitycurl, browser devtools, trivialcurl, GraphiQL/Playground, trivialNeeds 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

  1. 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-web proxy layer just to reach it from a browser, adding complexity the performance win rarely justifies for public traffic.
  2. 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.
  3. Forgetting client-streaming calls need an explicit "done sending" signal. The server-side handler for ImportOrders doesn't know the client is finished until .end()/equivalent is called — omitting it leaves the call hanging indefinitely.
  4. 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

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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).

Follow-ups interviewers ask
  • 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.

1. Which change breaks protobuf compatibility?
Numbers identify fields.
2. Live price updates for one subscription fit…
One request, many responses.
3. Why can't browsers call gRPC directly?
Proxy or Connect protocol.
4. What should every gRPC call set?
Prevents stuck calls.