System Design APIs
gRPC
Imagine an Uber ride request. Your phone talks to an API edge over JSON. Inside the datacenter, matching, pricing, and ETA services call each other thousands of times per second. Verbose JSON keys and ad hoc schemas start to hurt.
That is where (a way for one program to call another program's functions over the network) shines: typed contracts, compact messages instead of JSON text, and streams between services you control. Think "compiled API," not "human-curl-friendly."
Internal call journey
Write a .proto contract
Declare messages and RPC methods in one schema file that both sides share.
The compiler emits client and server code in each language from that file.
Call like a local function
Application code calls GetUser(id); Protobuf bytes travel over HTTP/2 underneath.
Each hop passes on the remaining time budget so slow calls fail fast.
Try it: gRPC deadline budget across hops
Step through gateway, service, and leaf RPC hops. Each stage spends time on auth and serialization, then passes the remaining deadline downstream.
Gateway
Spent 0 ms · Remaining budget 400 ms
Orders service
Spent 0 ms · Remaining budget 0 ms
Inventory RPC
Spent 0 ms · Remaining budget 0 ms
- Final status
- in flight
- Time spent
- 0 ms
- Original deadline
- 400 ms
Propagate one deadline from the client. Each hop subtracts elapsed time before calling the next service.
What actually happens in a gRPC call
Go back to the Uber datacenter. When the matching service needs a rider's profile, nobody hand-writes an HTTP request. You define messages (data shapes) and service methods in a .proto file, and the Protobuf compiler (protoc) generates a for the caller and a server interface for the callee, in Go, Java, Python, C++, Rust and other languages, all from that one file. Application code then reads user = userService.GetUser(id). It looks local, but it is a network call: serialization, transport, and error handling were all generated for you.
On the wire, replaces repeated JSON key strings with small field numbers, which is why the messages are smaller and cheaper to parse (the wire section below shows the exact bytes). The call travels over , where many RPCs share one connection instead of opening a new one each time, and that same connection supports (one request with many responses, many requests with one response, or both directions at once) for feeds, uploads, and chat-like flows without inventing a second protocol.
Errors come back as a structured status code (such as DEADLINE_EXCEEDED or NOT_FOUND) in trailers, which are headers sent at the end of a response, rather than 's mix of HTTP codes and ad hoc JSON bodies. Where gRPC stops and REST begins is the subject of the pattern section further down. First, a little history explains why anyone wanted a compiled API at all.
Why gRPC was introduced
The design above exists because plus JSON became too expensive for a large platform team running enormous volumes of internal RPCs. Three costs drove the change.
Repeated JSON field names consume bytes, and parsing text has a CPU cost. A binary schema can reduce both for suitable message shapes, while generated clients provide an explicit contract across languages. The size and CPU savings depend on payloads, compression, libraries, and hardware; benchmark representative traffic rather than assuming a universal speedup. REST APIs can also use schemas and generated clients, so type safety is not exclusive to gRPC.
gRPC applies typed service contracts and generated clients to communication between programs in different languages. The useful design lesson is the contract boundary: teams can evolve implementations independently while agreeing on message fields, method semantics, errors, and deadlines. REST or GraphQL can serve the product edge while gRPC connects controlled backend services; this is one common arrangement rather than a mandatory topology.
How to choose gRPC
Knowing what does is not the same as knowing when to reach for it. Three questions settle most decisions.
1. Do you control both ends? Internal microservices, polyglot codegen (one schema, many languages), and schema evolution with reserved field numbers fit gRPC. Public curl-friendly APIs usually do not.
2. Are payload size, CPU, or streaming first-class? High-QPS east-west traffic (services calling each other inside the datacenter) and continuous streams benefit from and . Low-traffic CRUD rarely does.
3. Have you fixed bigger bottlenecks? Serialization savings rarely beat N+1 database queries or missing caches. Propose gRPC when internal efficiency is a real requirement, not as a default upgrade from REST.
Simple rule of thumb
Use gRPC for internal service-to-service calls. Keep or for browsers and partners. Always plan , an or client-side balancing, and how you will inspect binary traffic.
Where gRPC belongs: REST at the edge, gRPC inside
The rule of thumb draws the line at the gateway. This is what the two sides of that line look like in a real deployment.
Public and browser clients speak (or ) to the API or BFF edge (a backend-for-frontend layer that handles auth, rate limits, and translation). Inside the datacenter, services speak to each other: binary, fast, typed, and stream-friendly. gRPC fits inside because you control both clients and servers, schemas evolve with reserved fields, and latency and CPU matter. REST fits outside because it is curl-friendly, GETs are CDN-cacheable, JSON tooling is universal, and partners never have to install a protobuf toolchain.

External traffic (REST/HTTP) enters via web servers. Internal microservices communicate via gRPC: binary Protobuf over HTTP/2.
Protocol Buffers on the wire
The pattern above calls gRPC compact and typed, and both words come from Protocol Buffers. gRPC is not a brand-new wire protocol: it is as the transport, for serialization (turning objects into bytes), and .proto files as the contract. This section opens a message up and shows the bytes.
Protocol Buffers
Binary serialization
Service Definitions
.proto RPC methods
HTTP/2 Transport
Multiplexing + streams
gRPC
Typed, fast, cross-language RPC
The same user record in JSON and in Protobuf
JSON: text, human-readable, every key repeated.
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"roles": ["admin", "user"],
"active": true
}
// Total: ~95 bytes on the wire
// Every key is a string: "id", "name", "email"
// Repeated in EVERY message
// Parser must scan character-by-characterProtobuf: binary, machine-optimized, field numbers instead of names.
message User {
int32 id = 1; // field number, not value
string name = 2;
string email = 3;
repeated string roles = 4;
bool active = 5;
}
// Total: ~31 bytes on the wire!
// No field names sent: just field numbers
// Varint encoding: small ints = 1 byte
// Parser reads tag → type → value (zero scanning)What the wire actually looks like
The comments in the code blocks claim 95 bytes versus 31. Here are the actual bytes for the first three fields, so you can see where the difference comes from.
JSON on the wire (text)
{"id":123,"name":"Alice","email":"alice@example.com"}
Every character is a byte. Keys are repeated verbatim, and the quotes, colons, and braces are all overhead.
Protobuf on the wire (binary)
08 7B 12 05 41 6C 69 63 65 1A 11 61 6C 69 63 65 ...
08 = field 1, varint type. 7B = 123. 12 = field 2, string. 05 = 5 bytes follow. No keys, no quotes.
Why Protobuf parses so much faster
Size is only half the saving. The tag-length-value layout above also lets the parser jump by offset instead of scanning characters, which is where the CPU win comes from.
JSON: parse by scanning
- The parser reads character by character, because it cannot know where a value ends.
- It finds a quote, reads the key string, finds the colon, then works out the type.
- Numbers arrive as text, so
"123"must go throughatoi()before use. - Escape sequences, unicode, and nested objects all add branches to the hot loop.
- Result: roughly 1.2 μs per message on the benchmark below.
Protobuf: read by offset
- One tag byte tells the parser the field number and wire type instantly.
- A varint is read byte by byte until the most significant bit is zero, then done.
- A string carries its length first, so the parser can
memcpy()without scanning. - There is no escaping, no quotes, no colons, and no braces to skip over.
- Result: roughly 0.2 μs per message, which is about six times faster.
Worked example: Protobuf bytes at internal QPS
Those per-message savings look tiny until you multiply them by internal call volume.
Take the same user record: ~95 bytes as JSON versus ~31 bytes as (about 3x smaller). At 100K internal RPCs per second that is ~9.5 MB/s versus ~3.1 MB/s of payload before framing, so roughly 6.4 MB/s saved continuously (about 553 GB/day). Pair that with propagation, so a 500 ms client budget shrinks at each hop instead of resetting (the deadlines accordion below shows the chain), and the efficiency survives contact with a slow backend.
Numbers to know
- 95 bytes JSON versus 31 bytes Protobuf for the sample user record: about 3x smaller.
- 1.2 μs versus 0.2 μs to parse one message: about 6x less CPU on hot paths.
- 3 to 10x smaller is the typical payload range once messages carry many repeated keys.
- About 90% of gRPC calls are unary, so the streaming modes are the exception, not the rule.
- A proxy-based balancer adds ~0.5 ms per hop; a gRPC-Web proxy adds ~1 to 2 ms.
Performance benchmarks vs REST
The benchmark table puts the same comparison side by side and adds latency, throughput, and CPU, measured on workloads where serialization cost actually matters.
| Metric | gRPC (Protobuf) | REST (JSON) | Difference |
|---|---|---|---|
| Serialization | 0.2 μs | 1.2 μs | 6x slower |
| Payload size | 31 bytes | 95 bytes | 3x larger |
| Latency (p50) | 0.8 ms | 3.2 ms | 4x slower |
| Throughput | 125K req/sec | 35K req/sec | 3.5x less |
| CPU usage | 15% | 42% | 2.8x more |
Repeated JSON field names consume bytes, and parsing text has a CPU cost. A binary schema can reduce both for suitable message shapes, while generated clients provide an explicit contract across languages. The size and CPU savings depend on payloads, compression, libraries, and hardware; benchmark representative traffic rather than assuming a universal speedup. REST APIs can also use schemas and generated clients, so type safety is not exclusive to gRPC.
The .proto file: schema, streaming modes, and safe evolution
Generated code catches many contract mistakes before deployment, but it does not remove distributed-systems failures. A method can time out after the server has committed a change, and an older client may interpret a newly optional field differently from a newer one. Test the compatibility of message shapes and business meaning separately. Reserve removed field numbers and names so old bytes cannot later acquire an unrelated interpretation.
Every number above depends on the .proto file, so here is a realistic one, what its five methods mean, and how to change it later without breaking anyone.
A proto3 UserService
files declare typed messages and services; codegen turns them into in many languages. The coloured badge on each method is its RPC shape, explained in the next part.
UserService
GetUserRequest
User
Four streaming types
Unary calls return one response for one request. Server streaming sends a sequence of responses; client streaming sends a sequence of requests before one response; bidirectional streaming allows both sequences to progress independently. Ordering is preserved within an individual stream, but the application still needs message identifiers, reconnection behavior, and a way to resume business progress after the stream ends unexpectedly.
The badges on those five methods are the four RPC shapes gRPC supports. Unary plus server, client, and bidirectional model everything from a single lookup to a live feed on the same connection.
Unary RPC
One request, one response. Most calls are unary (see Numbers to know). Use cases: CRUD, auth, payment.
Server Streaming
One request, many replies. Use cases: feeds, stock tickers, log streaming, large result sets.
Client Streaming
Many chunks up, one reply back. Use cases: file upload, sensor data ingestion, batch import.
Bidirectional Streaming
Both sides send whenever they like. Use cases: chat, real-time gaming, collaborative editing, video calls.
Backward compatibility lives in field numbers
Notice that every field in the schema above carries a number. The wire format uses those numbers, never the names, which is what lets you evolve a schema while older binaries keep running.
Safe changes (old clients still work)
Breaking changes (avoid)
reserved 3, 8; // never reuse these numbers
reserved "old_field_name"; // document removed fieldsNew fields always take the next available number, and old clients skip numbers they do not know. That is what allows independent service deployments without coordinated rollouts across thousands of services.
Operating gRPC: load balancing, meshes, and browsers
A working .proto is only half the job. The long-lived connections that make gRPC fast also change how traffic is spread across servers, how it is secured, and how a browser can reach it at all.
Load balancing with gRPC
An L4 balancer normally chooses a backend for a TCP connection, so many RPC streams on that connection can remain concentrated on one server. An L7 proxy can select backends per RPC, while a configured client-side policy can distribute calls across known endpoints. Existing long streams are not freely movable when a new backend appears. Include connection draining and stream lifetime in the scaling plan rather than assuming that adding servers instantly redistributes load.
HTTP/2 multiplexing puts every RPC from a client onto one long-lived TCP connection. A load balancer working at the TCP level (L4) only sees that one connection, so it sends everything to a single server:
L4 load balancer (TCP-level): broken for gRPC
The balancer sees one connection and sends everything to the same server. Server B gets nothing.
L7 load balancer (HTTP/2-aware): required
Two solutions in practice
1. Proxy-based balancing. An (a service proxy, or an event-driven reverse proxy with a gRPC module) terminates HTTP/2 and opens new connections to the backends, so individual streams spread across servers. It adds about 0.5 ms of latency per hop.
2. Client-side balancing. gRPC has this built in with headless service discovery: the client learns every backend via DNS and picks a server per RPC. No proxy hop, lowest latency, and common for controlled internal fleets.
Service mesh (sidecar proxies plus a control plane)
A service mesh puts a proxy (sidecar) next to every app and adds the balancing above, plus security and resilience, without changing application code.
- (both sides present certificates) gives zero-trust security without shared API keys.
- L7 load balancing spreads multiplexed RPCs across backends, fixing the pinning problem above.
- Circuit breaking stops calling a backend that keeps failing, so it can recover.
- Retry with backoff spaces out attempts instead of hammering a struggling service.
- Distributed tracing follows one request across every hop for latency debugging.
- Traffic splitting sends a small canary share to a new version before everyone.
gRPC-Web for browsers
Browsers cannot make the raw HTTP/2 trailer-based calls that requires, and external consumers expect JSON rather than a protobuf toolchain. gRPC-Web bridges the gap for your own front end:
The browser sends an HTTP/1.1 POST with a Protobuf body. The proxy translates it to HTTP/2 gRPC, the backend answers normally, and the proxy translates back.
Limitations: unary and server streaming only, plus a proxy hop of about 1 to 2 ms. Consider if most clients are browsers.
Deadlines, metadata, interceptors, and errors
A deadline limits how long a caller is willing to wait for the overall operation. If an incoming request has 300 milliseconds left and local work uses 80, a downstream call must fit inside the remaining budget with room to return the result. Propagate cancellation so timed-out requests stop consuming resources, but do not interpret cancellation as rollback: a completed database write or external side effect may already exist. Language runtimes differ in automatic propagation, so verify configuration. See gRPC deadline documentation for the precise contract.
With traffic reaching the right servers, the next concern is what travels alongside each call: a time budget, headers, middleware, and a status when something fails. Propagating and auth headers through middleware is what makes cascading latency fail fast instead of hanging.
Deadline propagation (critical at scale)
Client sets deadline: 500ms
→ Service A receives: 480ms remaining
→ Service B receives: 420ms remaining
→ Service C receives: 350ms remaining
→ If exceeded: DEADLINE_EXCEEDED propagates back (fail fast!)Without deadlines, requests pile up into a cascading failure. With deadlines, each hop fails fast and frees its resources instantly.
Metadata (like HTTP headers for gRPC)
Metadata carries auth tokens, request IDs, and trace IDs alongside the call so interceptors and downstream hops can read them.
metadata.put("authorization", "Bearer token123")
metadata.put("x-request-id", "req-abc-456")
metadata.put("x-trace-id", "trace-789")Interceptors (middleware for gRPC)
An interceptor wraps every call so cross-cutting work runs in one place: Request → Auth → Logging → Metrics → Rate Limit → Handler.
intercept(request, handler) {
start = now()
validateAuth(request.metadata)
response = handler(request)
recordMetric("latency", now() - start)
return response
}Health checking protocol
service Health {
rpc Check(HealthCheckReq) returns (HealthCheckResp);
rpc Watch(HealthCheckReq) returns (stream HealthCheckResp);
}Responses are UNKNOWN, SERVING, or NOT_SERVING. In container orchestration platforms the grpc-health-probe binary calls this service and controls pod readiness and liveness, which is the common setup for gRPC services running there.
Server reflection (discovering services at runtime)
$ grpcurl -plaintext localhost:50051 list→ userservice.UserService→ grpc.health.v1.Health$ grpcurl -plaintext localhost:50051 describe userservice.User→ message User { int32 id = 1; string name = 2; }
Reflection is like API documentation tooling or OpenAPI, but for gRPC: tools such as grpcurl and grpcui use it to list and describe services. Enable it in dev and disable it in prod for security.
Error model and status codes
A status such as UNAVAILABLE describes the observed failure, not proof that a write had no effect. Retry only operations whose semantics make repetition safe, and keep attempts inside a shared time and retry budget. If the application, proxy, and client library each retry three times independently, downstream attempts can multiply. Record attempts and final outcomes separately so a superficially good success rate does not conceal overload.
When a call fails, gRPC returns one of these structured codes in the trailers instead of REST's mix of HTTP codes plus ad hoc bodies. The retry-safe ones matter most for the failure timeline in the next section.
| Code | Name | When to Use |
|---|---|---|
| 0 | OK | Success |
| 1 | CANCELLED | Client cancelled request |
| 3 | INVALID_ARGUMENT | Bad input (like HTTP 400) |
| 4 | DEADLINE_EXCEEDED | Timeout (critical at scale!) |
| 5 | NOT_FOUND | Resource missing (like HTTP 404) |
| 7 | PERMISSION_DENIED | Auth failed (like HTTP 403) |
| 8 | RESOURCE_EXHAUSTED | Rate limited (like HTTP 429) |
| 13 | INTERNAL | Server bug (like HTTP 500) |
| 14 | UNAVAILABLE | Transient, safe to retry |
A status can also carry typed details, so the client learns which field was wrong rather than parsing a free-text message:
status {
code: INVALID_ARGUMENT
message: "Email format invalid"
details: [BadRequest { field_violations: [
{ field: "email", description: "Must contain @" }
] }]
}Failure timeline, limits, and trade-offs
Everything above assumes deadlines and balancing are in place. Here is what happens when they are not, the rule that must always hold, and the limits that keep out of many stacks.
Timeline: missing deadlines and retry amplification
T0 Checkout → Inventory (no deadline) → Inventory slow under load T1 Checkout threads block; client SDK retries UNAVAILABLE ×3 T2 Mesh retry policy also retries → 9× attempts per user click T3 Inventory queue deepens; DEADLINE never fires → cascade to Cart T4 Fix: propagate deadline, one retry budget, retry only idempotent RPCs
Binary efficiency does not help if hung RPCs and multi-layer retries hold every thread. The fix at T4 is the deadline chain from the accordion above plus a single retry budget shared by the SDK and the mesh.
Correctness invariant
What must never become false
The remaining budget never increases across hops, and retries stay inside one client budget with idempotent semantics (safe to repeat) for the methods you retry. Wire compatibility is field-number based: removing or reusing a field number must never reinterpret old bytes as a new meaning for live clients.
Why gRPC is not used more often
gRPC fits many controlled service-to-service environments, but its advantages come with code generation, schema management, proxy compatibility, and binary debugging work. Browser clients may need gRPC-Web support or a translating edge. A small JSON API may be easier to operate when serialization is a negligible part of its cost. Compare the workload and clients before treating either approach as an upgrade.
Use gRPC when
- Internal microservices call each other and you control both the client and the server.
- Low-latency requirements under 5 ms make serialization a visible share of the budget.
- Streaming data such as logs, events, and feeds fits the native stream types.
- Polyglot systems need one contract, since codegen covers 10+ languages from one .proto file.
- Mobile backends sit behind a gateway and smaller payloads matter on every call.
- High-throughput paths above 1M RPC/sec make CPU per message the dominant cost.
Use REST instead when
- Public APIs need developer experience and curl-ability more than they need saved bytes.
- Browser-heavy apps would otherwise pay the gRPC-Web proxy overhead on every request.
- Simple CRUD gains nothing measurable, and gRPC only adds complexity to the stack.
- Third-party integrations depend on JSON being universal and needing no toolchain.
- Small teams cannot afford Protobuf schema management on top of everything else.
- Debugging is the priority, since JSON is human-readable in any terminal or browser.
gRPC deadlines and contract evolution
Deadline propagation
Assume the client allows 300 milliseconds. If the gateway spends 40 milliseconds before calling service A, at most 260 milliseconds remain. If A spends another 80 milliseconds, service B has at most 180 milliseconds, less any reserve for returning the response. A fresh 300-millisecond timeout at every hop would violate the original deadline. Application work must also observe cancellation and release resources.
Safe proto changes
Add optional fields, reserve removed field numbers, avoid changing field meaning or type, and keep old clients working during rolling deploys.
Payload and bandwidth budget
Header overhead
Assume each request carries 2 KB of headers and the service receives 50,000 requests per second. Multiplying bytes per request by requests per second gives 100 MB/s of request headers alone, using decimal units. This excludes response bodies and lower-level overhead. Reducing a cookie helps repeatedly because the browser may attach it to every matching request.
Field trimming
8KB response trimmed to 800B saves 7.2KB. Across 1B reads, that is about 7.2TB less transfer.
Compression
100KB JSON compressed to 20KB saves 80KB. Across 10M responses, that is about 800GB less egress.
Fan-out
One 600B event to 10K subscribers becomes 6MB of outbound payload, before protocol and encryption overhead.
How to choose REST, gRPC, or GraphQL
Choose the API style by client type, payload shape, caching needs, and operational maturity.
REST
Best default for browser and public HTTP APIs: cacheable resources, simple debugging, visible URLs, and broad infrastructure support.
gRPC
Best for controlled service-to-service calls: typed contracts, compact binary payloads, deadlines, streaming, and high request volume.
GraphQL
Best when many clients need different nested shapes: fewer round trips, less over-fetching, but harder caching and stricter query governance.
When debugging feels stuck
Split latency into client wait, gateway time, upstream fan-out, serialization, payload size, and cache behavior before changing protocols.
Simple rule of thumb
Start with REST for public clients, gRPC for busy internal RPC, and GraphQL only when nested shape flexibility is worth the caching and governance cost.
Wrap-up
You have seen the bytes, the schema, the operational pieces, and the failure. Use this section to turn that into an answer you can give out loud, then test yourself with the exercises and follow the references for the primary sources.
How to explain this in an interview
Treat as an internal systems choice, and cover four things: the .proto file is the contract and generated clients reduce drift across languages; multiplexing carries many RPCs and native on one connection; , cancellation, retries, and status codes must propagate across service hops; and long-lived channels need an or client-side balancing, with public and browser clients reaching the system through , , or gRPC-Web gateways.
Debugging questions to ask
- Is the latency in serialization, server handling, queueing, or waiting on another RPC?
- Are deadlines too long, missing, or being reset at each hop instead of propagated?
- Are retry policies at the SDK and mesh layers multiplying load during partial outages?
- Are clients pinned to one backend because the balancer only sees one TCP connection?
- Do engineers have
grpcurl, reflection, tracing, and request IDs to inspect a live call?
Exercises
- Deadline budget. A client budget is 400 ms across three hops. Assign per-hop timeouts so the leaf still has at least 80 ms, and name the status you return when the budget is exhausted.
- Rename without breaking. You need to rename a Protobuf field for clarity. How do you ship it without breaking old binaries that still use the wire number?
- Uneven backends. An L4 balancer sees one TCP connection per client carrying 200 multiplexed streams. Why can backends look uneven, and which balancing mode do you choose?
Topic-specific references
- gRPC Core Concepts : channels, stubs, streaming, and deadlines from the project itself.
- gRPC Status Codes : which codes are retry-safe, the input to your retry policy.
- Protocol Buffers Language Guide (proto3) : field numbers and the compatibility rules behind the safe-change table.
Key takeaway
Keep the edge/internal split from the pattern section: gRPC between services you control, REST or GraphQL for everyone else. Its size and CPU wins are real, but binary efficiency only helps when you can operate it: deadlines, L7 balancing, and field-number discipline are part of the choice, not extras.
Practice and further reading
Use these exercises to connect the lesson to a design decision. State your assumptions, trace what happens to one request, and explain the rule that must remain true even during a failure. That rule is the design's correctness invariant.
Focus for this lesson: Use protobuf contracts, deadlines, flow control, status semantics, and compatibility rules for efficient service-to-service RPC and streaming.
Explain the decision
Why must a gRPC deadline propagate downstream?
Read the reasoning
Without propagation, abandoned upstream requests continue consuming capacity; each hop should receive a smaller remaining budget and stop obsolete work.
Apply it to a production scenario
A bidirectional stream slowly consumes responses and server memory rises. What is missing?
Read one approach
Honor HTTP/2 flow control, bound application queues, pause producers, expose blocked-stream metrics, and cancel on deadline or disconnect.
Work through the numbers
A 120 ms end-to-end deadline spends 15 ms at the gateway and reserves 20 ms for return transit. What reaches the backend?
Read the worked calculation
At most 85 ms, and internal hops should subtract their own overhead instead of resetting the deadline.
Reason through a failure
What must a protobuf rollout preserve?
Read the recovery reasoning
Old readers must safely ignore new fields, field numbers must never be reused, and semantic changes need a versioned migration.
Sources and further study
Continue with Realtime Updates (choose a protocol).