The idea: a function call that is really an HTTP/2 stream
gRPC lets a program call a function on another machine as if it were local: user = stub.GetUser({id: 42}). Underneath, every call is one HTTP/2 stream. The request goes out as a HEADERS frame and DATA frames. The answer comes back as HEADERS, DATA and a last HEADERS frame, the trailers, which carry the call's result: grpc-status. The messages are Protocol Buffers bytes, not JSON.
:status.The canvas follows calls from the client process (left: the app's calls, the stub turning messages into bytes, the channel and its two subchannels) over one HTTP/2 connection per backend (middle: requests fly above each line, responses below it) to the server processes S1 and S2 (right: their connections and running handlers). Under them, the table lists each call's stream with its state, the server's send window and the status, and the frame log shows the full headers of the last frames. Every frame takes 20 ms one way; the clock jumps from one event to the next.
The .proto contract and the generated stub
Both sides start from a .proto file (shown on the canvas). It names a service, its methods and the messages they take and return, and gives every field a number: int32 id = 1; string name = 2;. The protoc compiler generates a client stub (a class with one method per RPC) and a server base class for many languages, so a Go client can call a Java server. The method's full name becomes the HTTP path: /user.v1.UserService/GetUser.
Protobuf on the wire
Protobuf writes each field as a tag and a value. The tag is (field number << 3) | wire type: 0 for a varint (a number in 7-bit groups), 2 for length-delimited bytes (strings, nested messages). So id: 42 is 08 2A, and name: "Ann" is 12 03 41 6E 6E. Field names never travel; a field left at its default is not sent at all. The receiver needs the same .proto to read the bytes back, and fields it does not know are skipped, which is why adding a field with a new number is a safe change.
gRPC puts a 5-byte prefix in front of every message: one byte saying whether it is compressed, then the length as 4 bytes, big-endian. The prefix lets a message span several DATA frames and several messages share one. The stub box on the canvas shows the prefix in grey and the protobuf bytes in the call's colour.
A call on HTTP/2: headers, messages, trailers
A call opens a new stream (the client numbers them 1, 3, 5, …). The request headers are always :method POST, the :path of the method, content-type: application/grpc and te: trailers, plus grpc-timeout when the call has a deadline. The client's last DATA frame carries END_STREAM: the stream is then half-closed (local), the client will send nothing more. The server answers with response headers (:status 200), its messages, and the trailers with END_STREAM, which close the stream (Demo 1).
:status is 200 even when the call fails. The real result is grpc-status in the trailers, because a server streaming 1 000 messages only knows at the end whether everything went well. When there is nothing to send, the server sends a trailers-only response: one HEADERS frame with :status 200, grpc-status, grpc-message and END_STREAM (Demo 6: NOT_FOUND for user 7).
Four kinds of RPC
The four kinds differ only in how many messages each side sends on the same stream. In the .proto they differ by the word stream.
| kind | .proto | messages | on the page |
|---|---|---|---|
| unary | rpc GetUser(Req) returns (User) | 1 → 1 | Demo 1 |
| server streaming | returns (stream Order) | 1 → n, then trailers | Demo 2: 3 Orders |
| client streaming | rpc UploadMetrics(stream Metric) | n → 1, the answer after the client's END_STREAM | Demo 3: 3 Metrics → Summary |
| bidirectional | (stream ChatMsg) returns (stream ChatMsg) | both sides at any time | Demo 3: Chat |
Status codes
Every call ends with exactly one status. gRPC has 17 codes; the common ones:
| code | name | meaning | retry? |
|---|---|---|---|
| 0 | OK | success | — |
| 1 | CANCELLED | the caller cancelled | no |
| 3 | INVALID_ARGUMENT | bad request, whatever the server's state | no |
| 4 | DEADLINE_EXCEEDED | the deadline ran out; the work may still have happened | only if the call is idempotent |
| 5 | NOT_FOUND | the thing does not exist | no |
| 8 | RESOURCE_EXHAUSTED | quota or rate limit | later, with backoff |
| 12 | UNIMPLEMENTED | the server has no such method | no |
| 13 | INTERNAL | a bug on the server | no |
| 14 | UNAVAILABLE | the backend is down or unreachable | yes, the usual retryable code |
| 16 | UNAUTHENTICATED | missing or bad credentials | no |
Deadlines and cancellation
A gRPC call should always have a deadline: the moment after which the caller no longer wants the answer. The stub sends the time left in the grpc-timeout header (160m = 160 ms; units H M S m u n). In Demo 5 the call starts at t = 0 with a 200 ms deadline, waits 40 ms for the connection, and so sends grpc-timeout: 160m. The handler needs 300 ms. At t = 200 the client gives up: the app gets DEADLINE_EXCEEDED, and RST_STREAM(CANCEL) tells S1 to stop. The server's handler sees its context cancelled; when it would have finished, it sends nothing.
A server that calls other services passes the same context on, so the remaining deadline travels with every downstream call and they all stop together. Without deadlines, a slow backend makes callers pile up waiting forever (see Circuit Breaker and Bulkhead).
Multiplexing and flow control
All calls to one backend share its single connection, each on its own stream, and their frames interleave (Demo 4: streams 1, 3 and 5 at once). Nothing is held back for another call, as long as no TCP packet is lost (HTTP/1.1 vs HTTP/2 vs HTTP/3 shows TCP head-of-line blocking).
Each stream has a flow-control window: how many DATA bytes the sender may still send. It starts at 65 535 bytes and shrinks with every DATA frame; the receiver gives bytes back with WINDOW_UPDATE once its application has read them. In Demo 7 the server streams five 20 000-byte Orders to an app that needs 100 ms for each. After three Orders the server's window is 5 535 bytes, and its stream.Send() blocks. Each order the app finishes frees 20 000 bytes, more than the 16 384 the client waits for (a quarter of the window, as in grpc-go), so it sends WINDOW_UPDATE +20000 and the server sends one more. A slow reader slows its own stream down instead of filling the server's memory.
Channels, load balancing and retries
The client's channel resolves a name (dns:///users:50051) to addresses and keeps a subchannel per address, each in a connectivity state: IDLE, CONNECTING, READY or TRANSIENT_FAILURE. A load-balancing policy picks a READY subchannel for every call:
- pick_first (the default) connects to the first address that works and sends every call there until that connection fails.
- round_robin connects to every address and takes the next READY one for each call. In Demo 8 four calls go S1, S1, S1, S1 with pick_first and S1, S2, S1, S2 with round_robin.
Because a connection lives for hours and carries every call, a plain L4 (TCP) load balancer in front of the servers does not spread gRPC calls: it chooses a backend once per connection. Use client-side balancing as here, or an L7 proxy (Envoy, a service mesh) that understands HTTP/2 and picks per stream.
A retry policy in the service config says which statuses may be retried and how often (maxAttempts, initialBackoff, retryableStatusCodes). A call can only be retried until it is committed, that is until the client has received response headers: after that, part of the answer may already be in the app's hands. In Demo 9 S1 is killed while its slow handler is still working. 20 ms later the client sees the connection reset; the call has no response headers yet, so after 100 ms of backoff it goes out again with grpc-previous-rpc-attempts: 1. pick_first moves to S2, connects, and attempt 2 succeeds. Only retry what is safe to run twice: S1 may have done part of the work.
gRPC vs REST with JSON
| gRPC | REST + JSON | |
|---|---|---|
| contract | .proto, code generated for both sides | optional (OpenAPI) |
| payload | protobuf, binary, small | JSON text, readable |
| transport | HTTP/2 only | any HTTP version |
| streaming | both directions, built in | add SSE or WebSocket |
| errors | 17 status codes in trailers | HTTP status codes |
| browsers | only through gRPC-Web | native |
| debugging | needs tools (grpcurl) | curl, the browser |
Browsers' fetch cannot read HTTP/2 trailers or control frames, so a browser speaks gRPC-Web: the trailers are sent as a last length-prefixed block in the body, and a proxy or the server translates. Server-to-browser push without gRPC is on WebSocket vs SSE vs Long Polling.
To follow one call across several services, see Distributed Tracing with OpenTelemetry: the trace context rides in the call's metadata as traceparent.
See REST vs GraphQL vs gRPC for the same screen loaded through all three, with round trips and bytes counted.
What the page leaves out
- TCP and TLS handshakes (the connection is ready one round trip after the HTTP/2 preface); see How an HTTP Connection Is Established. HPACK header compression, connection-level flow control and frame sizes.
- Keepalive pings,
GOAWAYon a graceful shutdown, idle timeouts and reconnect backoff timing. - Message compression (gzip), interceptors, metadata, authentication, reflection and health checking.
- Name resolution changes, xDS, weighted and outlier-aware policies, retry jitter, hedging and retry throttling.
- The client reads small messages at once and the server's handlers take fixed times; the slow reader is only in Demo 7.