How gRPC Works: Protocol Buffers, HTTP/2 Streaming, and Production Patterns in TypeScript
A deep dive into how gRPC actually works: Protocol Buffer encoding and schema evolution, the four RPC communication patterns, HTTP/2 multiplexing mechanics, interceptors, deadline propagation, load balancing with gRPC, the health checking protocol, and when to reach for gRPC over REST or GraphQL. TypeScript examples using @grpc/grpc-js and connect-es.
gRPC sits at an interesting intersection: it solves real problems that REST cannot address cleanly (schema enforcement, streaming, low-latency binary transport) while introducing operational complexity that most teams underestimate. Understanding how it actually works, not just how to make a hello-world server, is what separates teams that use gRPC effectively from teams that fight it constantly.
This article covers the full picture: Protocol Buffer encoding on the wire, schema evolution rules, all four RPC patterns with TypeScript code, how gRPC maps onto HTTP/2 frames, interceptors and middleware, deadline propagation, load balancing (the part most guides skip), the gRPC health checking protocol, and an honest tradeoffs comparison.
Protocol Buffer Encoding
Protocol Buffers encode data as a binary sequence of tag-value pairs. Each field in your .proto schema has a field number, and the encoding uses that number as a compact tag rather than the full field name. This is the primary reason protobuf payloads are 5-10x smaller than equivalent JSON.
The wire format uses a concept called wire types. Field number 1 of type int32 encodes as 0x08 (field 1, wire type 0 = varint). A string uses wire type 2 (length-delimited). A nested message is also wire type 2. The encoder writes the tag byte, then the value using the minimal number of bytes needed.
Varints are the key compression mechanism. The integer 1 encodes in one byte; 127 in one byte; 128 requires two bytes; 16383 requires two bytes. For most integer fields in a typical service (IDs, counts, timestamps in seconds), the values are small and the encoding is compact. Compare this to JSON where {"user_id": 1234567} uses 20 bytes including field name overhead, while protobuf encodes the same as roughly 5 bytes.
Schema evolution works because the encoding is based on field numbers, not field names or position. The rules:
- You can add new fields freely. Old clients ignore unknown fields.
- You can remove fields by marking them
reserved. Never reuse a field number. - You can rename fields. The name does not appear on the wire.
- You cannot change a field’s type in incompatible ways (e.g.,
stringtoint32). optionalfields that are not present are simply absent from the encoded bytes, not serialized as null.
syntax = "proto3";
package users.v1;
message User {
string id = 1;
string email = 2;
string display_name = 3;
int64 created_at_unix = 4;
// Field 5 was `phone_number`, now reserved
reserved 5;
reserved "phone_number";
// New field in v2, safe to add
string avatar_url = 6;
repeated string roles = 7;
}
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User);
rpc UpdateUsers(stream UpdateUserRequest) returns (UpdateUsersResponse);
rpc WatchUserEvents(WatchRequest) returns (stream UserEvent);
}
Use buf for code generation. Running buf generate against this schema produces TypeScript types, service stubs, and client constructors that you import directly. The generated code is where type safety enters: you cannot call GetUser with a malformed request because the TypeScript types prevent it at compile time.
HTTP/2 Multiplexing and gRPC Framing
gRPC runs exclusively on HTTP/2. This is not a dependency you can swap out; the wire protocol is built around HTTP/2 semantics.
HTTP/2 introduces streams: multiple logical request/response exchanges multiplexed over a single TCP connection. Each stream has an integer ID. The client opens a stream by sending a HEADERS frame with the request headers. Data flows in DATA frames. The stream ends when one side sends a frame with the END_STREAM flag set.
gRPC maps one RPC call to one HTTP/2 stream. Request headers include:
:method: POST
:scheme: https
:path: /users.v1.UserService/GetUser
:authority: api.internal:443
content-type: application/grpc
grpc-timeout: 5S
te: trailers
The content-type: application/grpc header is how the server identifies gRPC traffic. The te: trailers header tells the HTTP/2 stack that the client understands HTTP/2 trailers, which is how gRPC communicates the final status code.
Each protobuf message is framed inside a DATA frame with a 5-byte prefix: 1 byte compression flag (0 = uncompressed, 1 = compressed) and 4 bytes for the message length. The server’s final status arrives in HTTP/2 trailers: grpc-status: 0 for OK, and grpc-message if there was an error.
This framing is why HTTP/1.1 cannot carry gRPC natively: HTTP/1.1 has no trailer mechanism. grpc-web proxies (like Envoy) translate between gRPC and a variant that can run over HTTP/1.1, but the translation adds overhead.
Multiplexing over one TCP connection is both a benefit and a complication. The benefit: no connection-per-request overhead, no head-of-line blocking at the HTTP layer. The complication: when the TCP connection itself experiences packet loss, all streams on that connection stall. This is the same TCP HOL blocking problem that HTTP/3/QUIC addresses.
The Four Communication Patterns
gRPC defines four RPC patterns. Each maps to a different combination of single/streaming request and single/streaming response.
Unary RPC
One request, one response. Functionally identical to REST from a call-site perspective, but with binary encoding and generated types.
import * as grpc from "@grpc/grpc-js";
import { UserServiceClient } from "./generated/users/v1/users_grpc_pb";
import { GetUserRequest } from "./generated/users/v1/users_pb";
const client = new UserServiceClient(
"api.internal:443",
grpc.credentials.createSsl()
);
function getUser(userId: string): Promise<User.AsObject> {
return new Promise((resolve, reject) => {
const request = new GetUserRequest();
request.setId(userId);
client.getUser(request, (error, response) => {
if (error) {
reject(error);
return;
}
resolve(response.toObject());
});
});
}
Server Streaming
The client sends one request. The server responds with a stream of messages, each arriving as a separate DATA frame on the same HTTP/2 stream.
function listUsers(orgId: string): void {
const request = new ListUsersRequest();
request.setOrgId(orgId);
const stream = client.listUsers(request);
stream.on("data", (user: User) => {
console.log("received user:", user.getId());
});
stream.on("end", () => {
console.log("stream complete");
});
stream.on("error", (err: Error) => {
console.error("stream error:", err.message);
});
}
Use server streaming when the response set is large and you want the client to process results incrementally rather than buffering everything before returning. A paginated list that might return 10,000 records is a good candidate.
Client Streaming
The client sends a stream of messages. The server processes them and returns a single response when the client signals it is done.
function updateUsers(updates: UpdatePayload[]): Promise<UpdateSummary> {
return new Promise((resolve, reject) => {
const stream = client.updateUsers((error, response) => {
if (error) {
reject(error);
return;
}
resolve(response.toObject());
});
for (const update of updates) {
const request = new UpdateUserRequest();
request.setUserId(update.userId);
request.setDisplayName(update.displayName);
stream.write(request);
}
stream.end();
});
}
Client streaming fits bulk write operations where you want the server to accumulate a batch and acknowledge once, avoiding one round trip per write.
Bidirectional Streaming
Both sides send streams of messages independently. Neither side needs to wait for the other to finish before sending more messages.
function watchUserEvents(userIds: string[]): void {
const stream = client.watchUserEvents();
stream.on("data", (event: UserEvent) => {
console.log("event:", event.getType(), "for user:", event.getUserId());
});
stream.on("end", () => {
console.log("server closed the stream");
});
// Send which users to watch
for (const userId of userIds) {
const request = new WatchRequest();
request.setUserId(userId);
stream.write(request);
}
// Can write more requests later, or end the stream
stream.end();
}
Bidirectional streaming is the right tool for chat-style protocols, real-time game state sync, or any scenario where both sides are producing events at variable rates.
Interceptors
gRPC interceptors are the middleware layer. They wrap calls at the client or server side, giving you a clean place to implement cross-cutting concerns: logging, metrics, authentication, retry logic, and tracing.
import * as grpc from "@grpc/grpc-js";
const loggingInterceptor: grpc.Interceptor = (options, nextCall) => {
const start = Date.now();
return new grpc.InterceptingCall(nextCall(options), {
start(metadata, listener, next) {
next(metadata, {
onReceiveStatus(status, nextStatus) {
const duration = Date.now() - start;
console.log({
method: options.method_definition.path,
status: status.code,
duration_ms: duration,
});
nextStatus(status);
},
onReceiveMessage: listener.onReceiveMessage?.bind(listener),
onReceiveMetadata: listener.onReceiveMetadata?.bind(listener),
});
},
});
};
const client = new UserServiceClient("api.internal:443", grpc.credentials.createSsl(), {
interceptors: [loggingInterceptor],
});
On the server side, interceptors work through the ServerInterceptor interface. A common pattern is an auth interceptor that reads the authorization header from incoming metadata and rejects calls that lack a valid token before they reach your handler.
function authInterceptor(
methodDescriptor: grpc.MethodDefinition<any, any>,
call: grpc.ServerUnaryCall<any, any>
): void {
const metadata = call.metadata;
const token = metadata.get("authorization")[0] as string;
if (!token || !isValidToken(token)) {
call.emit("error", {
code: grpc.status.UNAUTHENTICATED,
message: "missing or invalid token",
});
}
}
Deadline Propagation and Cancellation
gRPC’s deadline model is one of its strongest operational features. Every call can carry a deadline, expressed as an absolute timestamp or a relative timeout. The server receives the deadline and knows how much time is left. If the client cancels or the deadline expires, the server-side call is cancelled automatically.
function getUserWithDeadline(userId: string, timeoutMs: number): Promise<User.AsObject> {
return new Promise((resolve, reject) => {
const request = new GetUserRequest();
request.setId(userId);
const deadline = new Date();
deadline.setMilliseconds(deadline.getMilliseconds() + timeoutMs);
client.getUser(request, { deadline }, (error, response) => {
if (error) {
if (error.code === grpc.status.DEADLINE_EXCEEDED) {
reject(new Error(`getUser timed out after ${timeoutMs}ms`));
return;
}
reject(error);
return;
}
resolve(response.toObject());
});
});
}
The propagation pattern matters in a chain of services: when Service A receives a request with a 2-second deadline and calls Service B, it should pass a deadline that accounts for the time already elapsed. If 500ms has already passed, it should forward a 1.5-second deadline, not the original 2 seconds. This prevents downstream services from doing work that the upstream caller will never receive.
function propagateDeadline(
incomingMetadata: grpc.Metadata,
callStartMs: number
): Date | undefined {
const remainingHeader = incomingMetadata.get("grpc-timeout")[0] as string;
if (!remainingHeader) return undefined;
// grpc-timeout format: "5S" (seconds), "500m" (milliseconds), etc.
const elapsed = Date.now() - callStartMs;
const originalMs = parseGrpcTimeout(remainingHeader);
const remainingMs = originalMs - elapsed;
if (remainingMs <= 0) throw new Error("deadline already exceeded");
const deadline = new Date();
deadline.setMilliseconds(deadline.getMilliseconds() + remainingMs);
return deadline;
}
Load Balancing Challenges
This is where most gRPC production problems originate. REST clients open a TCP connection per request (or pool short-lived connections). gRPC clients hold one long-lived HTTP/2 connection to a server. Standard L4 load balancers (like classic AWS ELB in TCP mode) distribute connections at setup time but do not distribute requests across the connection’s lifetime. If you have 10 server instances and 10 client instances, each client connects to one server. Load is distributed once, not continuously.
Two approaches solve this:
Proxy-based (L7) load balancing. A proxy like Envoy or nginx understands HTTP/2 framing and can route individual RPC calls across a pool of backends. Envoy is the standard choice: it speaks gRPC natively, can do weighted round-robin at the stream level, and integrates with service meshes like Istio.
# Envoy cluster config for gRPC backend pool
clusters:
- name: user_service
type: STRICT_DNS
lb_policy: ROUND_ROBIN
http2_protocol_options: {}
load_assignment:
cluster_name: user_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: user-service.internal
port_value: 443
Client-side load balancing. The client resolves the service address to multiple IPs (via DNS or a service registry), opens connections to each, and distributes calls itself. @grpc/grpc-js supports this with the round_robin load balancer policy.
const client = new UserServiceClient(
"dns:///user-service.internal:443",
grpc.credentials.createSsl(),
{
"grpc.service_config": JSON.stringify({
loadBalancingConfig: [{ round_robin: {} }],
}),
}
);
With DNS-based client-side balancing, each DNS A record IP becomes a separate connection target. Calls rotate across all connections. The catch: your DNS resolver must return multiple A records, and you need a short TTL so new instances register quickly. Kubernetes headless services (ClusterIP: None) work well here because they return pod IPs directly rather than a single virtual IP.
Health Checking Protocol
gRPC defines a standard health checking protocol in grpc/health/v1. Services implement the Health service, which exposes a Check unary RPC and a Watch server-streaming RPC.
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3;
}
ServingStatus status = 1;
}
The service field can be empty (checks overall server health) or a specific service name like users.v1.UserService. Kubernetes liveness and readiness probes can call this endpoint using grpc-health-probe, a small binary that speaks the protocol and exits with a non-zero code when the status is not SERVING.
import { HealthImplementation } from "grpc-health-check";
const healthImpl = new HealthImplementation({
"": proto.grpc.health.v1.HealthCheckResponse.ServingStatus.SERVING,
"users.v1.UserService":
proto.grpc.health.v1.HealthCheckResponse.ServingStatus.SERVING,
});
// Later, if a dependency goes down:
healthImpl.setStatus(
"users.v1.UserService",
proto.grpc.health.v1.HealthCheckResponse.ServingStatus.NOT_SERVING
);
Using connect-es for Browser Clients
@grpc/grpc-js runs in Node.js. Browser clients cannot use it directly because browsers do not expose the HTTP/2 framing APIs gRPC needs. The traditional answer was grpc-web with an Envoy proxy. The modern answer is connect-es.
Connect is a protocol that runs over standard HTTP/1.1 or HTTP/2, uses the same .proto schemas, and is compatible with standard gRPC servers when they have a Connect proxy in front. connect-es generates TypeScript clients that work in the browser without a grpc-web proxy.
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { UserService } from "./generated/users/v1/users_connect";
const transport = createConnectTransport({
baseUrl: "https://api.example.com",
});
const client = createClient(UserService, transport);
// Unary call
const user = await client.getUser({ id: "usr_123" });
// Server streaming - works in the browser
for await (const user of client.listUsers({ orgId: "org_abc" })) {
console.log(user.email);
}
The for await pattern on streaming calls is one of connect-es’s cleaner design choices. You get an async iterator without manually wiring up event listeners.
Connect also has a Node.js transport for service-to-service calls if you want consistent client code across browser and server. The tradeoff: the Connect protocol is slightly different from raw gRPC on the wire, so direct gRPC-to-gRPC calls between services should still use @grpc/grpc-js for full compatibility.
Tradeoffs
| Dimension | gRPC | REST (JSON) | GraphQL |
|---|---|---|---|
| Latency | Low (binary, multiplexed) | Medium (text, one connection per request or pooled) | Medium (same as REST for transport) |
| Payload size | Small (binary, no field names) | Large (text, full field names) | Variable (client selects fields) |
| Schema enforcement | Strong (protobuf compiler) | Optional (OpenAPI, manual) | Strong (schema + resolver validation) |
| Streaming | All four patterns natively | SSE for server push only | Subscriptions via WebSocket |
| Browser support | Requires proxy or connect-es | Native | Native |
| Code generation | Required (buf/protoc) | Optional (openapi-generator) | Optional (graphql-codegen) |
| Developer experience | High setup cost | Familiar, low friction | High setup cost |
| Debuggability | Binary, needs tooling (grpcurl, Postman) | Plain text, curl works | Plain text, curl works |
| Load balancing | Requires L7 proxy or client-side LB | Standard L4/L7 | Standard L4/L7 |
| Ecosystem maturity | Large but less than REST | Very large | Growing |
| When to use | Internal service-to-service, streaming, high throughput | Public APIs, external integrations, simple CRUD | Flexible data fetching, frontend-driven queries |
Production Considerations
TLS is required in production. gRPC over plaintext (grpc.credentials.createInsecure()) is only for local development or within a trusted service mesh that handles mTLS at the sidecar layer. Do not skip this.
Connection management matters. Long-lived HTTP/2 connections can hit server-side max-age limits, network middleboxes that kill idle connections, or memory limits on the server. Implement connection keepalives on both sides and handle reconnection in your client.
const client = new UserServiceClient(
"api.internal:443",
grpc.credentials.createSsl(),
{
"grpc.keepalive_time_ms": 30000,
"grpc.keepalive_timeout_ms": 5000,
"grpc.keepalive_permit_without_calls": 1,
"grpc.http2.max_pings_without_data": 0,
}
);
Reflection in development. Register the gRPC reflection service so tools like grpcurl can query your server without needing the .proto files locally. Disable it in production or restrict it to internal networks.
Error codes carry semantics. gRPC defines 17 status codes. Use them correctly: NOT_FOUND for missing resources, ALREADY_EXISTS for conflicts, INVALID_ARGUMENT for bad input, UNAVAILABLE for transient failures that the client should retry. Clients and load balancers make retry decisions based on these codes. Returning INTERNAL for everything discards that information.
Monitor with gRPC-specific metrics. Standard HTTP request rate metrics do not capture gRPC stream duration or the number of messages within a stream. Prometheus grpc_server_* metrics from the grpc-prometheus package give you per-method call rates, error rates, and latency histograms that account for the streaming model.
When not to use gRPC. If your primary consumers are external developers calling your API from curl or Postman, REST is a better default. If you need flexible query shapes that vary by screen or use case, GraphQL serves that better. gRPC’s strength is internal service communication where schema enforcement, performance, and streaming matter more than browser compatibility and tooling familiarity.
Closing
gRPC’s wire protocol is not magic: it is protobuf encoding packed into HTTP/2 frames with a 5-byte length prefix and status communicated through HTTP/2 trailers. Once you see that clearly, the rest follows logically. The four communication patterns map onto HTTP/2 stream semantics. Interceptors wrap calls at the transport layer. Deadlines propagate as a header and cancel automatically when they expire. Load balancing requires either a layer-7 proxy or a client that understands how to distribute connections across multiple server IPs.
The operational complexity is real, but it is bounded. For internal services handling high call volumes or requiring streaming, the tradeoff is worth making.
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 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.
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.