How gRPC Works: Protocol Buffers, HTTP/2 Framing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer varint encoding and field tags, HTTP/2 frame structure and multiplexing, all four RPC communication patterns with TypeScript, deadline propagation, load balancing strategies, interceptor chains, and production considerations for health checking, reflection, and debugging with grpcurl.
gRPC’s performance claims are real, but they come from specific mechanical decisions made at the protocol level. Most engineers who use gRPC understand it at the surface: write a .proto file, generate code, call the service. What they miss is the layered architecture that makes it fast, where things break in production, and why load balancing behaves so differently from REST.
This article goes from .proto definition to raw wire bytes, covers all four RPC patterns, explains how deadlines propagate through a call graph, and addresses the operational realities most tutorials skip.
Protocol Buffer Encoding: Field Tags, Wire Types, and Varints
Protocol Buffers encode each field as a tag-value pair. The tag encodes two things in one integer: the field number from your .proto schema and a wire type constant. The wire type tells the decoder how to interpret the bytes that follow.
There are six wire types in use:
| Wire Type | ID | Used For |
|---|---|---|
| Varint | 0 | int32, int64, bool, enum, sint32, sint64 |
| 64-bit | 1 | fixed64, sfixed64, double |
| Length-delimited | 2 | string, bytes, nested messages, repeated fields |
| 32-bit | 5 | fixed32, sfixed32, float |
The tag byte is computed as (field_number << 3) | wire_type. For a field with number 1 and wire type 0 (varint), the tag is (1 << 3) | 0 = 0x08. For a string field at field number 2, the tag is (2 << 3) | 2 = 0x12.
Varints are the compression engine. They use the most significant bit of each byte as a continuation flag: if bit 7 is set, another byte follows. Values 0-127 fit in one byte. Values 128-16383 require two bytes. For typical application integers (IDs, counts, version numbers), this produces encodings 3-5x smaller than the equivalent decimal string in JSON.
Consider a simple message:
syntax = "proto3";
message OrderEvent {
string order_id = 1; // wire type 2, tag = 0x0A
int32 quantity = 2; // wire type 0, tag = 0x10
double price = 3; // wire type 1, tag = 0x19
bool shipped = 4; // wire type 0, tag = 0x20
}
If order_id = "ord-7829", quantity = 3, price = 49.99, shipped = false:
- Tag
0x0A+ varint length 8 + 8 UTF-8 bytes forord-7829= 10 bytes - Tag
0x10+ varint3= 2 bytes - Tag
0x19+ 8 bytes little-endian IEEE 754 double = 9 bytes shippedisfalse(zero value in proto3) and is omitted entirely
Total: 21 bytes. An equivalent JSON object is roughly 65 bytes without whitespace. The ratio holds across realistic payloads because proto3 omits default (zero) values entirely, field names never appear on the wire, and small integers encode in one or two bytes.
Schema Evolution Rules
The encoding’s tag-based structure makes schema evolution mechanical:
- Add fields freely. Old decoders ignore unknown field numbers.
- Remove a field by marking it
reserved. Never reuse a field number in a different type. - Rename fields without consequence. Names are not on the wire.
- Do not change a field’s wire type. An
int32field re-typed asstringwill corrupt existing data. sint32andsint64use zigzag encoding:0maps to0,-1maps to1,1maps to2. Use these for fields that are frequently negative; plainint32encodes negative numbers as 10-byte varints.
message OrderEvent {
string order_id = 1;
int32 quantity = 2;
double price = 3;
bool shipped = 4;
// Field 5 removed in v2
reserved 5;
reserved "legacy_region";
// Added in v2, old clients skip it
string carrier_code = 6;
}
HTTP/2 Transport: Frames, Streams, and Multiplexing
gRPC does not run on HTTP/1.1. It is built specifically on HTTP/2 and depends on three HTTP/2 features: stream multiplexing, header compression (HPACK), and bidirectional data flow.
An HTTP/2 connection is a single TCP connection that carries many logical streams simultaneously. Each stream has a unique integer ID. Streams opened by clients use odd IDs (1, 3, 5…); server-pushed streams use even IDs. Each stream is independent: a slow stream on ID 3 does not block the stream on ID 5.
Every HTTP/2 message is broken into frames. Each frame has a 9-byte header:
+-----------------------------------------------+
| Length (24 bits) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31 bits) |
+=+=============================================================+
| Frame Payload (0 to 2^24-1 octets) |
+---------------------------------------------------------------+
The relevant frame types for gRPC:
HEADERS(type 0x1): opens a stream, carries HTTP headers compressed with HPACKDATA(type 0x0): carries payload bytesRST_STREAM(type 0x3): immediately terminates a stream (used for cancellation)WINDOW_UPDATE(type 0x8): flow control, governs how many bytes can be sent before acknowledgmentGOAWAY(type 0x7): graceful shutdown, tells the remote end the highest stream ID processed
How gRPC Maps to HTTP/2
One gRPC call equals one HTTP/2 stream. The call lifecycle:
- Client sends a
HEADERSframe on a new stream, withEND_HEADERSset. Headers include::method: POST:path: /package.ServiceName/MethodNamecontent-type: application/grpcgrpc-timeout: 5000m(milliseconds)te: trailers(required by gRPC spec)
- Client sends one or more
DATAframes containing the length-prefixed protobuf message. Each gRPC message is prefixed with 5 bytes: 1 compression flag byte + 4 bytes for the message length. - After sending all request messages, the client sets
END_STREAMon the finalDATAframe. - The server sends a
HEADERSframe with the response headers (:status: 200,content-type: application/grpc). - Server sends
DATAframes for each response message. - Server finishes with a
HEADERSframe (called trailers) carryinggrpc-statusand optionallygrpc-message. This frame hasEND_STREAMset.
The 5-byte length-prefix is gRPC’s message framing on top of HTTP/2 DATA frames. A single DATA frame can contain multiple gRPC messages, or one gRPC message can span multiple DATA frames. The receiver accumulates bytes and uses the length prefix to know where each message starts and ends.
HPACK Header Compression
HTTP/2’s HPACK compresses repeated headers by maintaining a shared index table between client and server. On the first request, :method: POST costs the full bytes. On the second request, it costs one index byte. For a service making thousands of calls per second to the same endpoint, HPACK compression on headers like :path and content-type is significant.
The Four Communication Patterns
The .proto service block defines which of the four patterns each RPC uses. The pattern changes how streams are opened and when END_STREAM is sent.
Unary
One request, one response. The most common pattern.
// Generated proto:
// rpc GetOrder(GetOrderRequest) returns (Order);
import * as grpc from "@grpc/grpc-js";
import { OrderServiceClient } from "./generated/order_grpc_pb";
import { GetOrderRequest } from "./generated/order_pb";
const client = new OrderServiceClient(
"order-service:50051",
grpc.credentials.createSsl()
);
function getOrder(orderId: string): Promise<Order> {
return new Promise((resolve, reject) => {
const req = new GetOrderRequest();
req.setOrderId(orderId);
client.getOrder(req, (err, response) => {
if (err) return reject(err);
resolve(response);
});
});
}
Server Streaming
One request, multiple responses. The server sends a stream of messages and then signals completion. Useful for large result sets, subscription-style feeds, or progress updates.
// rpc ListOrderEvents(ListEventsRequest) returns (stream OrderEvent);
function streamOrderEvents(orderId: string): void {
const req = new ListEventsRequest();
req.setOrderId(orderId);
const call = client.listOrderEvents(req);
call.on("data", (event: OrderEvent) => {
console.log("event:", event.toObject());
});
call.on("end", () => {
console.log("stream complete");
});
call.on("error", (err: grpc.ServiceError) => {
console.error("stream error:", err.code, err.message);
});
}
Client Streaming
Multiple requests, one response. The client sends a stream of messages and the server waits until the stream is complete before responding. Useful for bulk uploads or aggregation.
// rpc BulkCreateOrders(stream CreateOrderRequest) returns (BulkCreateResponse);
function bulkCreate(orders: OrderData[]): Promise<BulkCreateResponse> {
return new Promise((resolve, reject) => {
const call = client.bulkCreateOrders((err, response) => {
if (err) return reject(err);
resolve(response);
});
for (const order of orders) {
const req = new CreateOrderRequest();
req.setCustomerId(order.customerId);
req.setItems(order.items);
call.write(req);
}
call.end(); // sends END_STREAM
});
}
Bidirectional Streaming
Both sides send independent streams. Neither has to wait for the other. The server does not need to receive all client messages before it starts responding.
// rpc ProcessOrders(stream OrderRequest) returns (stream OrderResult);
function processBidirectional(orders: OrderData[]): Promise<OrderResult[]> {
return new Promise((resolve, reject) => {
const results: OrderResult[] = [];
const call = client.processOrders();
call.on("data", (result: OrderResult) => {
results.push(result);
});
call.on("end", () => resolve(results));
call.on("error", reject);
for (const order of orders) {
const req = new OrderRequest();
req.setOrderId(order.id);
call.write(req);
}
call.end();
});
}
Bidirectional streaming maps to a single HTTP/2 stream where both endpoints independently set END_STREAM when they are done sending. The stream stays open until both sides have set END_STREAM.
Deadline Propagation and Cancellation
gRPC deadlines are expressed as an absolute time (not a duration). When service A calls service B with a 5-second deadline, and B then calls C, B should subtract the time already elapsed and propagate the remaining budget to C. Forwarding the original deadline value directly causes downstream services to accept work they cannot complete in time.
import * as grpc from "@grpc/grpc-js";
function propagateDeadline(
incomingDeadline: Date,
bufferMs = 100
): grpc.Deadline {
const remainingMs =
incomingDeadline.getTime() - Date.now() - bufferMs;
if (remainingMs <= 0) {
throw new grpc.StatusObject(
grpc.status.DEADLINE_EXCEEDED,
"deadline already exceeded before downstream call"
);
}
return new Date(Date.now() + remainingMs);
}
// In a server handler:
async function handleGetOrder(
call: grpc.ServerUnaryCall<GetOrderRequest, Order>,
callback: grpc.sendUnaryData<Order>
): Promise<void> {
const deadline = call.deadline as Date;
const downstreamDeadline = propagateDeadline(deadline);
const inventoryClient = getInventoryClient();
const meta = new grpc.Metadata();
inventoryClient.checkStock(
req,
{ deadline: downstreamDeadline },
(err, res) => {
if (err?.code === grpc.status.DEADLINE_EXCEEDED) {
callback({
code: grpc.status.DEADLINE_EXCEEDED,
message: "upstream call timed out",
});
return;
}
// ...
}
);
}
Cancellation is signaled via RST_STREAM. When the client cancels (for example, the user closes the connection), the server receives an error event with status CANCELLED. Servers should check this and stop processing expensive work, especially in streaming handlers.
Load Balancing: Where Most Teams Get Burned
gRPC connections are persistent long-lived TCP connections over HTTP/2. Standard L4 load balancers (classic AWS ELB, typical NLB in TCP mode) route at connection time. Once a connection is established, all traffic on that connection goes to the same backend. If your service scales to three replicas and all three clients connected before the third replica was added, the third replica gets zero traffic until connections are recycled.
The options, in order of increasing correctness:
Client-side load balancing. The gRPC client resolves DNS, gets a list of IPs, and applies a round-robin policy across them. In Kubernetes, this requires headless services (no ClusterIP, set clusterIP: None) so DNS returns individual pod IPs rather than a single virtual IP.
const client = new OrderServiceClient(
"dns:///order-service.default.svc.cluster.local:50051",
grpc.credentials.createSsl(),
{
"grpc.service_config": JSON.stringify({
loadBalancingConfig: [{ round_robin: {} }],
}),
}
);
L7 proxy (Envoy, nginx, Linkerd, Istio). The proxy terminates the gRPC connection from the client and maintains its own pool of connections to backends. It can balance at the RPC level, not just the connection level. This is correct behavior but adds a network hop and operational complexity.
Look-aside load balancing. The client queries a dedicated load balancer service that returns a list of healthy backends, then the client connects directly. Used by large-scale gRPC deployments internally at Google. Rarely needed outside that scale.
For most Kubernetes deployments: use a headless service and client-side round-robin for internal gRPC. Add an L7 proxy (Envoy via a service mesh) when you need traffic splitting, circuit breaking, or retries with backoff.
Interceptors
Interceptors are middleware applied to every call. They are typed by the call direction: UnaryInterceptor and StreamingInterceptor on the client side; server-side interceptors wrap handler invocations.
// Client unary interceptor for request logging and metrics
const loggingInterceptor: grpc.Interceptor = (options, nextCall) => {
const start = Date.now();
const methodPath = options.method_definition.path;
return new grpc.InterceptingCall(nextCall(options), {
sendMessage: (message, next) => {
next(message);
},
onReceiveStatus: (status, next) => {
const durationMs = Date.now() - start;
console.log({
method: methodPath,
status: status.code,
durationMs,
});
next(status);
},
});
};
const client = new OrderServiceClient(
"order-service:50051",
grpc.credentials.createSsl(),
{
interceptors: [loggingInterceptor],
}
);
Common interceptor patterns: authentication (attach bearer tokens from a shared secret store), distributed tracing (inject and extract trace context from metadata), retry logic (re-issue idempotent calls on UNAVAILABLE), and deadline enforcement (reject calls that arrive with an already-expired deadline before doing any work).
Tradeoffs
| Dimension | gRPC | REST (JSON) | GraphQL | WebSocket |
|---|---|---|---|---|
| Payload size | Binary, 5-10x smaller | Text JSON, verbose | Text JSON, verbose | Binary or text (protocol-specific) |
| Schema enforcement | Strict, compile-time | Optional (OpenAPI) | Strict, runtime | None by default |
| Streaming | Native (4 patterns) | SSE for server push only | Subscriptions | Full duplex |
| Browser support | Requires grpc-web or connect-es proxy | Native | Native | Native |
| Code generation | First-class, multi-language | OpenAPI generators (variable quality) | Codegen available | None standard |
| Debuggability | Binary, needs grpcurl or Wireshark | curl, browser devtools | GraphiQL, curl | Limited tooling |
| Load balancing | Requires L7 or client-side config | Standard L4 works | Standard L4 works | Requires sticky sessions or L7 |
| Latency | Low (binary + H2 multiplexing) | Higher (text + header overhead) | Higher (JSON + N+1 potential) | Low (persistent connection) |
| Ecosystem maturity | Strong in backend polyglot | Ubiquitous | Growing | Fragmented |
| When to use | Internal service-to-service, high throughput, streaming | Public APIs, browser clients, simple CRUD | Flexible data fetching, frontend-driven queries | Real-time bidirectional, chat, gaming |
Production Considerations
Health checking. The gRPC Health Checking Protocol is a standard proto service defined by the gRPC project. Implement it so that orchestrators (Kubernetes liveness and readiness probes) can check your service without relying on TCP connection establishment alone.
// grpc.health.v1.Health is a standard service
// import "grpc/health/v1/health.proto" via buf schema registry
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
Use grpc-health-probe as the liveness probe binary in your Kubernetes pod spec. It speaks the health checking protocol natively. Do not use a TCP socket probe; it only checks that the port is open, not that the service is ready to handle RPCs.
Server reflection. Enable gRPC server reflection in development and staging environments. It lets tools like grpcurl discover your service schema without needing the .proto files locally.
# List services on a running server
grpcurl -plaintext localhost:50051 list
# Describe a service
grpcurl -plaintext localhost:50051 describe orders.v1.OrderService
# Make a unary call
grpcurl -plaintext \
-d '{"order_id": "ord-7829"}' \
localhost:50051 \
orders.v1.OrderService/GetOrder
Disable reflection in production if your schema contains sensitive internal details. The risk is low (reflection exposes schema, not data) but it is a common security review finding.
Retries. gRPC supports service config-driven retries. The server must set grpc-retry-pushback-ms to signal it is overloaded and the client should backoff. Only retry idempotent methods. UNAVAILABLE is safe to retry; INTERNAL is not.
const serviceConfig = {
methodConfig: [
{
name: [{ service: "orders.v1.OrderService", method: "GetOrder" }],
retryPolicy: {
maxAttempts: 3,
initialBackoff: "0.1s",
maxBackoff: "1s",
backoffMultiplier: 2,
retryableStatusCodes: ["UNAVAILABLE"],
},
},
],
};
const client = new OrderServiceClient(addr, creds, {
"grpc.service_config": JSON.stringify(serviceConfig),
"grpc.enable_retries": 1,
});
Keepalive. Long-lived gRPC connections over NAT or cloud load balancers get silently dropped after idle periods. Configure keepalive pings to detect dead connections before the next RPC fails.
const client = new OrderServiceClient(addr, creds, {
"grpc.keepalive_time_ms": 30000, // send ping every 30s
"grpc.keepalive_timeout_ms": 5000, // wait 5s for pong
"grpc.keepalive_permit_without_calls": 1, // ping even if no RPCs in flight
"grpc.http2.min_time_between_pings_ms": 10000,
});
Debugging with grpcurl. The most common debug workflow: use grpcurl to replay failing requests against a service directly, bypassing the caller. If the call succeeds directly but fails through the service mesh or load balancer, the problem is routing, not business logic. Use the -v flag to see raw header frames and response trailers including grpc-status codes.
Max message size. The default max message size is 4 MB. Large file transfers or bulk data loads will hit this. Either chunk your data into a streaming RPC or increase the limit explicitly. Increasing it arbitrarily is a footgun: a single large message blocks the HTTP/2 stream until it is fully received, which defeats multiplexing.
const server = new grpc.Server({
"grpc.max_receive_message_length": 16 * 1024 * 1024, // 16 MB
"grpc.max_send_message_length": 16 * 1024 * 1024,
});
gRPC’s performance comes from decisions made at every layer: varints in the encoding, HPACK compression on headers, binary framing over a multiplexed transport, and schema enforcement that catches contract drift before runtime. The operational complexity is real, particularly around load balancing and observability. But each piece of that complexity has a specific cause and a specific fix. Understanding the wire format is what makes the difference between debugging gRPC by guessing and debugging it by reading what actually happened.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.