How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
gRPC gives you a contract-first, binary, multiplexed transport with generated types on both ends of every call. That sounds like a lot of adjectives, but each one maps to a concrete mechanism. Understanding those mechanisms is what turns “I heard gRPC is faster” into knowing exactly why your latency dropped and what to reach for when it does not.
This article traces the path from .proto file to bytes on the wire, covering the code generation pipeline, how HTTP/2 carries gRPC calls, all four RPC patterns, channel and connection internals, deadline propagation, interceptors, load balancing up through xDS, and the health checking protocol.
The Protocol Buffer IDL and Code Generation Pipeline
Everything starts with a .proto file. It is an interface definition language: you describe your messages and service methods, and tooling generates language-native types and stubs from that description.
syntax = "proto3";
package orders.v1;
option go_package = "github.com/example/orders/gen/go/orders/v1";
message Order {
string id = 1;
string customer_id = 2;
repeated LineItem items = 3;
OrderStatus status = 4;
int64 created_at_unix = 5;
}
message LineItem {
string sku = 1;
int32 quantity = 2;
int64 unit_price_cents = 3;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
}
message GetOrderRequest {
string order_id = 1;
}
message ListOrdersRequest {
string customer_id = 1;
int32 page_size = 2;
string page_token = 3;
}
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (stream Order);
rpc CreateOrders(stream CreateOrderRequest) returns (CreateOrdersResponse);
rpc TrackOrders(stream TrackRequest) returns (stream OrderEvent);
}
The field numbers (the integers after each field name) are the encoding contract, not the names. They appear on the wire; names do not. This is why you can rename a field without breaking existing clients, and why reusing a field number after deleting a field is dangerous: old clients may still try to decode the bytes with the old type.
The buf Toolchain
protoc is the original Protocol Buffer compiler. buf is the modern replacement. It handles dependency resolution, lint enforcement, breaking change detection, and code generation through a declarative config file.
# buf.yaml
version: v2
modules:
- path: proto
deps:
- buf.build/googleapis/googleapis
lint:
use:
- DEFAULT
breaking:
use:
- FILE
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/ts
out: src/generated
- remote: buf.build/grpc/node
out: src/generated
opt:
- grpc_js
Running buf generate against this config produces TypeScript message types and _grpc_pb.js service stubs into src/generated. The breaking change detection (buf breaking --against .git#branch=main) is the part most teams skip and later regret: it catches field number reuse and type changes before they reach production.
The generated TypeScript for GetOrder looks roughly like this after the stub is created:
// Auto-generated — do not edit
export class OrderServiceClient {
constructor(
address: string,
credentials: grpc.ChannelCredentials,
options?: grpc.ClientOptions
) { /* ... */ }
getOrder(
request: GetOrderRequest,
callback: (error: grpc.ServiceError | null, response: Order) => void
): grpc.ClientUnaryCall;
listOrders(
request: ListOrdersRequest,
options?: grpc.CallOptions
): grpc.ClientReadableStream<Order>;
// ...
}
The field number encoding: take field number 1 with wire type 2 (length-delimited, used for strings, bytes, embedded messages). The tag byte is (1 << 3) | 2 = 0x0A. Field 3 with wire type 2 is (3 << 3) | 2 = 0x1A. These are the bytes you will see in a Wireshark capture after the 5-byte gRPC length prefix.
HTTP/2 as the Transport Layer
gRPC does not define its own transport; it maps onto HTTP/2 semantics precisely. Understanding HTTP/2 at the frame level explains several gRPC behaviors that appear magical otherwise.
Streams and Multiplexing
An HTTP/2 connection is a single TCP connection carrying multiple logical streams. Each stream has an odd integer ID assigned by the client (1, 3, 5, …). The server assigns even IDs for server-initiated streams, though gRPC only uses client-initiated streams.
One gRPC call equals one HTTP/2 stream. Ten concurrent RPCs share one TCP connection via ten concurrent streams. The frames for those ten streams are interleaved on the wire; the receiver reconstructs them by stream ID.
Frame types relevant to gRPC:
| Frame type | Purpose in gRPC |
|---|---|
| HEADERS | Opens the stream, carries request headers (method, path, timeout) |
| DATA | Carries the 5-byte-prefixed serialized protobuf message(s) |
| WINDOW_UPDATE | Flow control: signals readiness to receive more data |
| RST_STREAM | Immediately cancels a single stream |
| GOAWAY | Graceful shutdown: tells the other side which streams were processed |
| SETTINGS | Negotiates connection-level parameters (max concurrent streams, header table size) |
The gRPC framing inside a DATA frame is a 5-byte prefix followed by the serialized protobuf bytes. Byte layout:
Byte 0: Compression flag (0 = uncompressed, 1 = compressed with the agreed algorithm)
Bytes 1-4: Message length as a 4-byte big-endian unsigned integer
Bytes 5+: Serialized protobuf bytes
The final status of an RPC call arrives in HTTP/2 trailers: grpc-status: 0 for OK, grpc-status: 14 for UNAVAILABLE, and so on. This is why gRPC cannot run over HTTP/1.1 natively: HTTP/1.1 does not support trailers in a way that all implementations honor.
HPACK Header Compression
HTTP/2 compresses headers using HPACK, a static and dynamic table approach. The static table contains 61 frequently-used header fields (:method: GET, content-type: application/grpc, etc.). The dynamic table is a per-connection LRU cache that grows as new headers are sent.
For gRPC, the practical effect is that the second call to the same service sends almost no header bytes. The path (e.g., /orders.v1.OrderService/GetOrder), the content-type, the authority, and the scheme all hit the dynamic table after the first call and encode to single-byte references on subsequent calls. The headers that change per-call, like grpc-timeout and authorization tokens, still need to be sent in full. This compression is why gRPC has low per-call overhead even with rich header metadata.
The Four RPC Patterns
The service definition syntax signals the streaming mode:
rpc GetOrder(GetOrderRequest) returns (Order); // unary
rpc ListOrders(ListOrdersRequest) returns (stream Order); // server streaming
rpc CreateOrders(stream CreateOrderRequest) returns (CreateOrdersResponse); // client streaming
rpc TrackOrders(stream TrackRequest) returns (stream OrderEvent); // bidirectional
Unary
One request frame, one response frame. The HTTP/2 stream opens with HEADERS (END_HEADERS), carries one DATA frame (END_STREAM), receives HEADERS + DATA (END_STREAM) in response, and the trailers close the stream.
import * as grpc from "@grpc/grpc-js";
import { OrderServiceClient } from "./generated/orders/v1/orders_grpc_pb";
import { GetOrderRequest } from "./generated/orders/v1/orders_pb";
const client = new OrderServiceClient(
"orders.internal:443",
grpc.credentials.createSsl()
);
function getOrder(orderId: string, deadlineMs = 5000): Promise<Order.AsObject> {
return new Promise((resolve, reject) => {
const request = new GetOrderRequest();
request.setOrderId(orderId);
const deadline = new Date(Date.now() + deadlineMs);
client.getOrder(request, { deadline }, (err, response) => {
if (err) return reject(err);
resolve(response.toObject());
});
});
}
Server Streaming
The client sends one HEADERS + DATA (END_STREAM). The server sends multiple DATA frames, one per message, then trailers to close the stream. Each DATA frame is an independent framed protobuf message.
function streamOrders(customerId: string): Promise<Order.AsObject[]> {
return new Promise((resolve, reject) => {
const request = new ListOrdersRequest();
request.setCustomerId(customerId);
request.setPageSize(100);
const orders: Order.AsObject[] = [];
const call = client.listOrders(request);
call.on("data", (order: Order) => {
orders.push(order.toObject());
});
call.on("end", () => resolve(orders));
call.on("error", (err: Error) => reject(err));
});
}
The server controls the pace via HTTP/2 flow control. If the client is not reading fast enough, the server’s WINDOW is exhausted and it stops sending until the client’s stack sends WINDOW_UPDATE frames. This is automatic backpressure at the transport layer.
Client Streaming
The client sends multiple DATA frames, then signals END_STREAM. The server accumulates them and sends a single response with trailers.
function createOrders(payloads: CreatePayload[]): Promise<CreateOrdersResponse.AsObject> {
return new Promise((resolve, reject) => {
const call = client.createOrders((err, response) => {
if (err) return reject(err);
resolve(response.toObject());
});
for (const payload of payloads) {
const req = new CreateOrderRequest();
req.setCustomerId(payload.customerId);
req.setItems(payload.items.map(buildLineItem));
call.write(req);
}
call.end();
});
}
Bidirectional Streaming
Both sides send streams of messages independently on the same HTTP/2 stream. Neither waits for the other to finish.
function trackOrders(orderIds: string[]): void {
const call = client.trackOrders();
call.on("data", (event: OrderEvent) => {
console.log(
`order ${event.getOrderId()} transitioned to ${event.getStatus()}`
);
});
call.on("end", () => console.log("server closed tracking stream"));
call.on("error", (err: Error) => console.error("tracking error:", err.message));
for (const id of orderIds) {
const req = new TrackRequest();
req.setOrderId(id);
call.write(req);
}
// Keep the stream open; server will push events as they occur
// Call call.end() when done subscribing
}
Channel and Connection Management
A Channel in gRPC is the logical representation of a connection to a backend. It manages:
- The underlying HTTP/2 connections (there can be more than one)
- Connection state: IDLE, CONNECTING, READY, TRANSIENT_FAILURE, SHUTDOWN
- Reconnection backoff when connections drop
- Load balancer policy and subchannel management
The channel does not necessarily open a TCP connection immediately. If the first call is delayed, the connection is opened lazily. The state machine handles reconnects transparently: if a connection drops mid-stream, in-flight calls get UNAVAILABLE errors, and the channel reconnects in the background.
Keepalive configuration prevents middleboxes and server-side idle timeouts from silently killing long-lived connections:
const channel = new OrderServiceClient(
"orders.internal:443",
grpc.credentials.createSsl(),
{
// Send a ping every 30 seconds even if no calls are in flight
"grpc.keepalive_time_ms": 30_000,
// Fail the connection if no ping ack within 5 seconds
"grpc.keepalive_timeout_ms": 5_000,
// Allow pings even when there are no active calls
"grpc.keepalive_permit_without_calls": 1,
// No limit on pings during idle periods
"grpc.http2.max_pings_without_data": 0,
// Minimum time between pings (server-side enforcement)
"grpc.http2.min_time_between_pings_ms": 10_000,
}
);
These option key strings are non-intuitive and poorly documented outside the grpc-core C repository. The above set is the standard production baseline for persistent channels.
Deadline Propagation and Cancellation
Deadlines are sent in the grpc-timeout header as a compact encoded duration: "5S" for 5 seconds, "500m" for 500 milliseconds, "2H" for 2 hours. The server receives this and can check the remaining time before doing expensive work.
When a deadline expires or the client cancels the call, the gRPC runtime sends RST_STREAM with error code CANCEL on the HTTP/2 stream. The server-side handler’s context is cancelled, and any in-progress work should respect that cancellation.
The pattern that matters for service chains is subtracting elapsed time, not forwarding the original duration:
function buildDownstreamDeadline(
inboundDeadline: Date,
bufferMs = 50
): Date {
const remainingMs = inboundDeadline.getTime() - Date.now() - bufferMs;
if (remainingMs <= 0) {
throw Object.assign(new Error("deadline already exceeded upstream"), {
code: grpc.status.DEADLINE_EXCEEDED,
});
}
return new Date(Date.now() + remainingMs);
}
// In a handler that calls another service:
async function handleGetOrder(
call: grpc.ServerUnaryCall<GetOrderRequest, Order>,
callback: grpc.sendUnaryData<Order>
): Promise<void> {
const upstreamDeadline = call.getDeadline() as Date;
try {
const downstreamDeadline = buildDownstreamDeadline(upstreamDeadline);
const inventory = await inventoryClient.checkStock(
request,
{ deadline: downstreamDeadline }
);
// ...
} catch (err) {
callback(err as grpc.ServiceError);
}
}
Forwarding the original header value without subtracting elapsed time means downstream services operate on a budget that is already partly spent. The 50ms buffer accounts for network serialization overhead between services.
Interceptors
Interceptors are the gRPC equivalent of Express middleware: they wrap calls, run code before and after, and can short-circuit the call chain. Client interceptors run before the call leaves the process; server interceptors run after the request arrives but before the handler executes.
const authInterceptor: grpc.Interceptor = (options, nextCall) => {
return new grpc.InterceptingCall(nextCall(options), {
start(metadata, listener, next) {
const token = process.env.SERVICE_TOKEN;
if (token) {
metadata.add("authorization", `Bearer ${token}`);
}
next(metadata, listener);
},
});
};
const retryInterceptor: grpc.Interceptor = (options, nextCall) => {
let attempts = 0;
const maxAttempts = 3;
function attempt(
metadata: grpc.Metadata,
listener: grpc.Listener,
next: grpc.NextCall
) {
attempts++;
return new grpc.InterceptingCall(nextCall(options), {
start(m, l, n) {
n(m, {
...l,
onReceiveStatus(status, nextStatus) {
if (
status.code === grpc.status.UNAVAILABLE &&
attempts < maxAttempts
) {
setTimeout(() => attempt(metadata, listener, next), 100 * attempts);
return;
}
nextStatus(status);
},
});
},
});
}
return {
start(metadata, listener, next) {
attempt(metadata, listener, next);
},
} as grpc.InterceptingCall;
};
const client = new OrderServiceClient(
"orders.internal:443",
grpc.credentials.createSsl(),
{ interceptors: [authInterceptor, retryInterceptor] }
);
Interceptors compose in array order: [authInterceptor, retryInterceptor] means auth runs first on the way out, retry wraps the auth-decorated call. Server-side interceptors in @grpc/grpc-js use a similar but distinct ServerInterceptingCall API.
gRPC also supports retry via service config, which is more reliable than application-level interceptors because it runs at the channel layer and understands gRPC status codes directly:
const serviceConfig = {
methodConfig: [
{
name: [{ service: "orders.v1.OrderService", method: "GetOrder" }],
retryPolicy: {
maxAttempts: 3,
initialBackoff: "0.1s",
maxBackoff: "1s",
backoffMultiplier: 2,
retryableStatusCodes: ["UNAVAILABLE", "RESOURCE_EXHAUSTED"],
},
timeout: "5s",
},
],
};
const client = new OrderServiceClient(
"dns:///orders.internal:443",
grpc.credentials.createSsl(),
{
"grpc.service_config": JSON.stringify(serviceConfig),
}
);
Load Balancing: pick-first, round-robin, and xDS
gRPC channels support pluggable load balancing policies. This is where gRPC differs most from REST.
Why Standard L4 Load Balancing Fails
A gRPC client holds one HTTP/2 connection and multiplexes all calls over it. An L4 load balancer (operating at TCP/IP) distributes connections at setup time. If you have 20 server pods and 10 client instances, each client opens one connection to one pod. Load is not distributed across all 20 pods: each pod handles calls from one client, and 10 pods sit idle unless you happen to connect them to another client.
pick-first
The default policy. The resolver returns a list of addresses; the client connects to the first one. If it fails, it tries the next. All calls go to the single connected subchannel.
round_robin
The client opens a subchannel (TCP connection) to every resolved address and round-robins calls across all of them. Requires the name resolver to return multiple addresses.
const client = new OrderServiceClient(
"dns:///orders.internal:443",
grpc.credentials.createSsl(),
{
"grpc.service_config": JSON.stringify({
loadBalancingConfig: [{ round_robin: {} }],
}),
}
);
In Kubernetes, this requires a headless service (clusterIP: None). A standard ClusterIP service resolves to a single virtual IP. A headless service resolves to one A record per pod IP. With round_robin and a headless service, every pod receives calls proportional to the resolver’s refresh rate.
xDS
xDS is the discovery API from Envoy’s control plane protocol, now adopted by gRPC as a first-class load balancing mechanism. Instead of DNS, the gRPC channel connects to an xDS management server (like Istio’s istiod or a custom control plane) and receives dynamic cluster membership, endpoint weights, and routing rules over a long-lived gRPC stream.
Bootstrap config (JSON):
{
"xds_servers": [
{
"server_uri": "xds:///istiod.istio-system.svc.cluster.local:15010",
"channel_creds": [{ "type": "insecure" }]
}
],
"node": {
"id": "orders-service~10.0.1.5~orders-v1-abc123~default.svc.cluster.local",
"cluster": "orders-service"
}
}
With xDS, the client receives ADS (Aggregated Discovery Service) streams carrying:
- LDS (Listener Discovery): which listeners the client should use
- RDS (Route Discovery): routing rules per listener
- CDS (Cluster Discovery): cluster names and load balancing config
- EDS (Endpoint Discovery): the actual IP:port list per cluster with per-endpoint weights and health status
This lets you do traffic splitting, weighted routing, and locality-aware load balancing without a sidecar proxy. The gRPC client becomes the Envoy equivalent for service-to-service traffic.
Health Checking Protocol
gRPC defines a standard health checking service in grpc/health/v1. The schema is part of the gRPC ecosystem and recognized by Kubernetes, Envoy, and most service discovery systems.
import { HealthImplementation } from "grpc-health-check";
import * as health from "grpc-health-check/health";
const statusMap: Record<string, health.servingStatus> = {
"": health.servingStatus.SERVING,
"orders.v1.OrderService": health.servingStatus.SERVING,
};
const healthImpl = new HealthImplementation(statusMap);
// Attach to your server
healthImpl.addToServer(server);
// Mark a service degraded when a dependency is unhealthy
async function checkDependencies(): Promise<void> {
const dbHealthy = await checkDatabaseConnection();
const status = dbHealthy
? health.servingStatus.SERVING
: health.servingStatus.NOT_SERVING;
healthImpl.setStatus("orders.v1.OrderService", status);
}
setInterval(checkDependencies, 10_000);
The Watch streaming RPC sends a new HealthCheckResponse whenever the status changes, which is more efficient than polling Check repeatedly. Envoy uses Watch for endpoint health tracking.
For Kubernetes liveness and readiness probes, grpc-health-probe is a small binary that calls Check and exits non-zero on NOT_SERVING or connection failure:
livenessProbe:
exec:
command:
- /bin/grpc-health-probe
- -addr=:50051
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
exec:
command:
- /bin/grpc-health-probe
- -addr=:50051
- -service=orders.v1.OrderService
initialDelaySeconds: 5
periodSeconds: 5
Production Considerations
Use buf for codegen, not raw protoc. The breaking change detection alone is worth the migration. A field number collision between two services that evolved independently is a silent data corruption bug that does not surface until you deploy.
Never reuse field numbers. Mark removed fields with reserved. Add new fields with new numbers. This is not optional for any schema that has external consumers or long-lived clients.
Set deadlines on every call. The gRPC runtime does not impose a default timeout. A call without a deadline can hang indefinitely, holding the HTTP/2 stream open and consuming server-side goroutine or thread resources. Always pass a deadline.
Use GOAWAY for graceful shutdown. When your server process needs to drain, send GOAWAY with the last processed stream ID. In-flight calls below that stream ID are safe; calls above it were not started and the client should retry. This prevents request loss during rolling deploys.
Match keepalive settings between client and server. Servers have a minimum ping interval enforcement (GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS). If your client pings more frequently than the server allows, the server will send GOAWAY with error code ENHANCE_YOUR_CALM. Align the two configs.
Use grpcurl for debugging, not curl. grpcurl -plaintext -d '{"order_id": "ord_123"}' localhost:50051 orders.v1.OrderService/GetOrder works if you have registered the reflection service. Restrict reflection to internal networks in production.
Monitor per-method error rates, not just 5xx. gRPC errors are UNAVAILABLE, NOT_FOUND, INTERNAL, and so on. Map these to your observability platform explicitly. OpenTelemetry has a gRPC semantic convention that produces rpc.grpc.status_code attributes on spans.
Tradeoffs
| Dimension | gRPC | REST (JSON) | GraphQL | WebSocket |
|---|---|---|---|---|
| Payload size | Small: binary, no field names on wire | Large: text, full field names | Variable: client selects fields, but response is JSON | Application-defined |
| Schema enforcement | Compile-time via protobuf | Optional (OpenAPI, often skipped) | Runtime schema + resolver validation | None by default |
| Streaming | All four patterns via HTTP/2 | SSE for server push, no client streaming | Subscriptions over WebSocket | Full duplex, untyped |
| Browser support | Requires proxy or connect-es | Native | Native | Native |
| Code generation | Required (buf, protoc) | Optional (openapi-generator) | Optional (graphql-codegen) | None |
| Load balancing | Requires L7 proxy or client-side LB policy | Standard L4/L7 works | Standard L4/L7 works | L4 distributes connections, not messages |
| Debuggability | Binary: needs grpcurl, Postman, or Wireshark | curl works, plain text | curl works, Playground UIs | Binary or text, browser devtools |
| Connection model | Long-lived HTTP/2 multiplexed connection | Pooled short-lived or persistent HTTP/1.1 | Same as REST | Long-lived per-client connection |
| Latency | Low: binary encoding, multiplexed, HPACK | Medium: text parsing, per-request overhead | Medium: same transport as REST | Low for persistent connections |
| Ecosystem | Large but younger than REST | Mature, universal | Growing, strong frontend tooling | Niche per-protocol implementations |
| When to use | Internal service-to-service, high throughput, streaming | Public APIs, external integrations, browser-direct | Frontend flexible queries, BFF pattern | Real-time push to browsers |
Closing
The gRPC mental model is simple once you see the layers clearly: protobuf encodes messages to binary using field number tags, HTTP/2 multiplexes calls as streams with HPACK-compressed headers and trailers carrying the final status, and the gRPC framing layer adds a 5-byte length prefix inside each DATA frame. Everything above that, from interceptors to xDS load balancing, is built on those three layers. Knowing the wire format is what lets you debug by reading rather than guessing.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.
How Next.js Works Internally: The Compilation Pipeline, RSC Protocol, and Caching Architecture From Request to Render
A deep dive into the internal architecture of Next.js App Router. Covers the SWC/Turbopack compilation pipeline, the RSC wire protocol, the four-layer caching architecture, rendering strategies, middleware execution, Server Actions, and the self-hosted vs managed deployment tradeoffs.