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.

Time runs down. The client sends HEADERS on stream 1 with :method POST, :path /user.v1.UserService/GetUser, content-type application/grpc, te trailers and grpc-timeout 200m, then DATA with END_STREAM: 00, 00 00 00 02, 08 2A, 7 bytes. The server answers HEADERS with :status 200, DATA of 12 bytes holding User id 42 name Ann, and the trailers with grpc-status 0 and END_STREAM.
One unary call is five frames on one stream; the result is in the trailers, not in :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.

The same User with id 42 and name Ann: as JSON 22 bytes, one per character; as protobuf 7 bytes 08 2A 12 03 41 6E 6E, where 08 is the tag of field 1 varint, 2A is 42, 12 the tag of field 2 length-delimited, 03 the length and 41 6E 6E is Ann; on the gRPC wire a 5-byte prefix 00 00 00 00 07 comes first, 12 bytes in all.
The 22-byte JSON object is 7 bytes of protobuf; gRPC adds a 5-byte prefix to each message.

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.protomessageson the page
unaryrpc GetUser(Req) returns (User)1 → 1Demo 1
server streamingreturns (stream Order)1 → n, then trailersDemo 2: 3 Orders
client streamingrpc UploadMetrics(stream Metric)n → 1, the answer after the client's END_STREAMDemo 3: 3 Metrics → Summary
bidirectional(stream ChatMsg) returns (stream ChatMsg)both sides at any timeDemo 3: Chat

Status codes

Every call ends with exactly one status. gRPC has 17 codes; the common ones:

codenamemeaningretry?
0OKsuccess—
1CANCELLEDthe caller cancelledno
3INVALID_ARGUMENTbad request, whatever the server's stateno
4DEADLINE_EXCEEDEDthe deadline ran out; the work may still have happenedonly if the call is idempotent
5NOT_FOUNDthe thing does not existno
8RESOURCE_EXHAUSTEDquota or rate limitlater, with backoff
12UNIMPLEMENTEDthe server has no such methodno
13INTERNALa bug on the serverno
14UNAVAILABLEthe backend is down or unreachableyes, the usual retryable code
16UNAUTHENTICATEDmissing or bad credentialsno

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.

Left: a client sends four calls over one TCP connection through an L4 balancer, which forwards the whole connection to S1; S1 gets every call and S2 is idle. Right: a client channel with round_robin has one connection to S1 and one to S2; calls 1 and 3 go to S1, calls 2 and 4 to S2.
An L4 balancer picks once per connection, so every call lands on one backend; round_robin picks per call.

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

gRPCREST + JSON
contract.proto, code generated for both sidesoptional (OpenAPI)
payloadprotobuf, binary, smallJSON text, readable
transportHTTP/2 onlyany HTTP version
streamingboth directions, built inadd SSE or WebSocket
errors17 status codes in trailersHTTP status codes
browsersonly through gRPC-Webnative
debuggingneeds 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, GOAWAY on 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.