gRPC in TypeScript: Protocol Buffers, Streaming RPCs, and Service-to-Service Communication in Production
A practical guide to gRPC in TypeScript services. Covers protobuf schema design, code generation with buf, implementing unary and streaming RPCs with @grpc/grpc-js, interceptors, deadline propagation, and load balancing.
Most internal service communication starts with REST because it is familiar. Then you hit the edge cases: schema drift between services, verbose JSON payloads on high-frequency call paths, no native streaming, and the TypeScript types you wrote by hand diverging from what the server actually returns. gRPC solves all of these, at the cost of operational complexity that REST does not have.
This article covers the concrete mechanics of running gRPC in TypeScript services: protobuf design, code generation, implementing both unary and streaming RPCs, interceptors, deadline propagation, and what load balancing actually means for gRPC connections. It ends with an honest comparison of when REST is still the right call.
Why gRPC for Internal Services
The headline benefit is binary encoding. A JSON payload for a user object with 15 fields might weigh 400 bytes. The same data in protobuf is 60-80 bytes. At high call volumes (tens of thousands of RPC calls per second between services), that difference shows up in CPU time spent on serialization and network throughput.
The more durable benefit is schema enforcement. With REST, the contract between services lives in documentation, OpenAPI specs, or shared TypeScript types that drift out of sync. With protobuf, the schema is the source of truth: code generation produces the types, and the serialization layer enforces field presence at runtime. When field user_id is marked as a required string, you get a clear error at the boundary, not a Cannot read property 'id' of undefined three stack frames deep.
Streaming is where gRPC has no REST equivalent. HTTP/1.1 can simulate server-sent events, but client streaming and bidirectional streaming require either WebSockets or long-polling hacks. gRPC gives you all four RPC modes natively: unary, server streaming, client streaming, and bidirectional streaming.
The cost: gRPC requires HTTP/2, which many load balancers handle incorrectly at the connection level. Browser support requires a proxy (grpc-web). Debugging requires tools that understand binary framing. These are solvable problems, but they are real.
Schema Design with Protocol Buffers
Start with the .proto file. Schema quality here determines how painful future evolution will be.
// proto/user/v1/user.proto
syntax = "proto3";
package user.v1;
option go_package = "github.com/your-org/protos/gen/go/user/v1;userv1";
message User {
string id = 1;
string email = 2;
string display_name = 3;
int64 created_at_unix = 4;
UserStatus status = 5;
}
enum UserStatus {
USER_STATUS_UNSPECIFIED = 0;
USER_STATUS_ACTIVE = 1;
USER_STATUS_SUSPENDED = 2;
}
message GetUserRequest {
string user_id = 1;
}
message GetUserResponse {
User user = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
UserStatus status_filter = 3;
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
}
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc WatchUserEvents(WatchUserEventsRequest) returns (stream UserEvent);
}
A few rules that matter in production:
Field numbers are permanent. Once you ship a field number, you cannot reuse it. If you remove field 3, reserve it: reserved 3; reserved "old_field_name";. Otherwise a future developer adds a new field at number 3, and old clients reading the new payload interpret the new data as the old field type.
Use enums with an _UNSPECIFIED = 0 value. Proto3 defaults unset enum fields to 0. If your zero value is ACTIVE, you cannot distinguish between “not set” and “actively set to ACTIVE”. Name the zero value _UNSPECIFIED and treat it as a validation error at the application layer.
Prefer explicit request/response wrappers. rpc GetUser(string) returns (User) seems simpler but forces a breaking change when you need to add a field to the request later. Dedicated message types cost nothing and pay dividends.
Code Generation with buf
protoc works but buf is what you want in a team setting. It handles dependency management, breaking change detection, and plugin orchestration in a single config.
# buf.yaml
version: v2
lint:
use:
- DEFAULT
breaking:
use:
- FILE
# buf.gen.yaml
version: v2
plugins:
- plugin: es
out: src/gen
opt:
- target=ts
- plugin: connect-es
out: src/gen
opt:
- target=ts
Run buf generate and you get typed TypeScript classes and a Connect-compatible service client. For @grpc/grpc-js specifically, use protoc-gen-ts or ts-proto:
buf generate --template buf.gen.yaml
The generated output gives you typed request/response classes, service definitions, and client constructors. Do not edit generated files. They are artifacts, not source.
Add a buf breaking --against .git#branch=main check to CI. It catches field number reuse, removed RPCs, and type changes before they land.
Implementing a Unary RPC Server
import * as grpc from "@grpc/grpc-js";
import {
UserServiceService,
IUserServiceServer,
} from "./gen/user/v1/user_grpc_pb";
import {
GetUserRequest,
GetUserResponse,
User,
} from "./gen/user/v1/user_pb";
class UserServiceImpl implements IUserServiceServer {
[name: string]: grpc.UntypedHandleCall;
getUser(
call: grpc.ServerUnaryCall<GetUserRequest, GetUserResponse>,
callback: grpc.sendUnaryData<GetUserResponse>
): void {
const userId = call.request.getUserId();
if (!userId) {
return callback({
code: grpc.status.INVALID_ARGUMENT,
message: "user_id is required",
});
}
// Fetch from your data layer
getUserFromDb(userId)
.then((user) => {
if (!user) {
return callback({
code: grpc.status.NOT_FOUND,
message: `user ${userId} not found`,
});
}
const response = new GetUserResponse();
const protoUser = new User();
protoUser.setId(user.id);
protoUser.setEmail(user.email);
protoUser.setDisplayName(user.displayName);
protoUser.setCreatedAtUnix(Math.floor(user.createdAt.getTime() / 1000));
response.setUser(protoUser);
callback(null, response);
})
.catch((err) => {
console.error("getUser error", { userId, err });
callback({
code: grpc.status.INTERNAL,
message: "internal error",
});
});
}
}
function startServer(): void {
const server = new grpc.Server();
server.addService(UserServiceService, new UserServiceImpl());
server.bindAsync(
"0.0.0.0:50051",
grpc.ServerCredentials.createInsecure(),
(err, port) => {
if (err) {
console.error("server bind failed", err);
process.exit(1);
}
console.log(`gRPC server listening on port ${port}`);
server.start();
}
);
}
startServer();
Use grpc.status codes deliberately. INVALID_ARGUMENT means the client sent bad input and retrying is pointless. UNAVAILABLE means the server is temporarily unhealthy and retrying with backoff is appropriate. Mapping every error to INTERNAL destroys the semantic value of status codes.
Implementing a Server Streaming RPC
Server streaming is the right pattern when you need to push a large result set incrementally or push real-time events to a caller.
watchUserEvents(
call: grpc.ServerWritableStream<WatchUserEventsRequest, UserEvent>
): void {
const userId = call.request.getUserId();
const unsubscribe = eventBus.subscribe(`user:${userId}`, (event) => {
if (call.cancelled || call.destroyed) {
unsubscribe();
return;
}
const protoEvent = new UserEvent();
protoEvent.setUserId(userId);
protoEvent.setEventType(event.type);
protoEvent.setTimestampUnix(event.timestamp);
call.write(protoEvent, (err) => {
if (err) {
// Client disconnected or stream was closed
unsubscribe();
}
});
});
call.on("cancelled", () => {
unsubscribe();
});
call.on("close", () => {
unsubscribe();
});
}
The critical detail: always clean up subscriptions or listeners on cancelled and close. A streaming RPC that leaks event listeners will exhaust memory slowly in production. The call.write callback receives an error when the client has gone away, which is your second opportunity to clean up.
The Client Side with Deadlines
import * as grpc from "@grpc/grpc-js";
import { UserServiceClient } from "./gen/user/v1/user_grpc_pb";
import { GetUserRequest } from "./gen/user/v1/user_pb";
const client = new UserServiceClient(
"user-service:50051",
grpc.credentials.createInsecure()
);
function getUser(userId: string, deadlineMs = 3000): Promise<User> {
return new Promise((resolve, reject) => {
const request = new GetUserRequest();
request.setUserId(userId);
const deadline = new Date(Date.now() + deadlineMs);
client.getUser(request, { deadline }, (err, response) => {
if (err) {
return reject(err);
}
const user = response?.getUser();
if (!user) {
return reject(new Error("empty response"));
}
resolve(user);
});
});
}
Deadline propagation is not automatic. If service A receives a request with a 500ms remaining deadline and calls service B, you need to explicitly pass the remaining time to service B’s call. Without this, service B can run for its full timeout while service A’s deadline has already expired, leaving B doing work that will never reach a caller.
function getRemainingDeadline(call: grpc.ServerUnaryCall<any, any>): number {
const deadline = call.getDeadline();
if (deadline === Infinity) return 5000; // default
return Math.max(0, (deadline as Date).getTime() - Date.now());
}
Pass this remaining time when making downstream calls. If the remaining time is near zero, fail fast before making the call at all.
Interceptors for Logging and Auth
Interceptors are middleware for gRPC calls. On the server side, they wrap handler execution. On the client side, they wrap outgoing calls.
// Server-side logging interceptor
function loggingInterceptor(
methodDescriptor: any,
call: grpc.ServerInterceptingCall
): grpc.InterceptingCall {
const startTime = Date.now();
return new grpc.InterceptingCall(call, {
start(metadata, listener, next) {
next(metadata, {
...listener,
onReceiveMessage(message, nextMessage) {
nextMessage(message);
},
onSendMessage(message, nextMessage) {
nextMessage(message);
},
});
},
sendMessage(message, next) {
next(message);
},
halfClose(next) {
next();
},
cancel(next) {
next();
},
});
}
// Auth interceptor: validate JWT in metadata
const authInterceptor: grpc.ServerInterceptor = (methodDescriptor, call) => {
return new grpc.InterceptingCall(call, {
start(metadata, listener, next) {
const token = metadata.get("authorization")[0];
if (!token || typeof token !== "string") {
call.sendStatus({
code: grpc.status.UNAUTHENTICATED,
details: "missing authorization metadata",
metadata: new grpc.Metadata(),
});
return;
}
verifyJwt(token.replace("Bearer ", ""))
.then((claims) => {
// Attach claims for downstream use
(call as any).authClaims = claims;
next(metadata, listener);
})
.catch(() => {
call.sendStatus({
code: grpc.status.UNAUTHENTICATED,
details: "invalid token",
metadata: new grpc.Metadata(),
});
});
},
});
};
// Register interceptors on the server
const server = new grpc.Server({
interceptors: [authInterceptor],
});
Keep interceptors focused on cross-cutting concerns: auth, logging, tracing, rate limiting. Business logic belongs in the service implementation, not in interceptors.
Error Handling and Status Codes
gRPC status codes map directly to retry behavior in clients that implement the gRPC retry policy:
| Status Code | Meaning | Client Behavior |
|---|---|---|
OK | Success | None |
INVALID_ARGUMENT | Bad request data | Do not retry |
NOT_FOUND | Resource missing | Do not retry |
ALREADY_EXISTS | Duplicate write | Do not retry |
PERMISSION_DENIED | Auth failed | Do not retry |
UNAUTHENTICATED | No credentials | Do not retry |
RESOURCE_EXHAUSTED | Rate limited | Retry with backoff |
UNAVAILABLE | Server down temporarily | Retry with backoff |
DEADLINE_EXCEEDED | Timeout | Retry if idempotent |
INTERNAL | Server bug | Do not retry |
Return RESOURCE_EXHAUSTED from your rate limiting interceptor, not TOO_MANY_REQUESTS (which is HTTP, not gRPC). Clients that understand gRPC status codes will back off automatically when they see RESOURCE_EXHAUSTED.
Include the error detail in the details field, not just the message. For validation errors, the google.rpc.BadRequest error detail type lets you enumerate which fields failed, similar to a 422 with field-level errors in REST.
Load Balancing gRPC in Production
This is where gRPC operations diverge from REST operations. HTTP/1.1 load balancers work at the request level: each request gets assigned to a backend. HTTP/2 (which gRPC requires) uses persistent multiplexed connections. A naive L4 load balancer assigns a connection to a backend and then all requests on that connection go to the same pod.
The result: you can have 100 pods behind a load balancer, but if your gRPC clients hold one connection each and there are 10 clients, only 10 pods receive traffic.
The solutions, in order of operational cost:
Client-side load balancing. The client maintains connections to all backends and distributes calls across them. Works well when the client can discover backends (via DNS, Kubernetes service endpoints, or a service registry). @grpc/grpc-js supports this with the round_robin and grpclb built-in policies.
const client = new UserServiceClient(
"dns:///user-service.default.svc.cluster.local:50051",
grpc.credentials.createInsecure(),
{
"grpc.service_config": JSON.stringify({
loadBalancingConfig: [{ round_robin: {} }],
}),
}
);
The dns:/// prefix tells the client to resolve the DNS name and connect to all returned IPs, not just the first.
L7 load balancing via proxy. Envoy, Nginx (with HTTP/2 upstream), or a service mesh intercepts gRPC traffic at the application layer and load balances at the RPC level. This adds a network hop but removes the client-side complexity. This is the standard pattern in Kubernetes environments using Istio or Linkerd.
Headless Kubernetes services. For internal cluster traffic, create the service with clusterIP: None. DNS returns all pod IPs, which enables client-side round-robin without a service mesh. Combine with a connection pool that periodically re-resolves DNS to pick up pod changes.
The wrong answer: a standard Kubernetes ClusterIP service with gRPC clients that open long-lived connections. All traffic concentrates on whichever pod the connection was assigned to.
gRPC vs REST vs GraphQL for Internal Services
| Dimension | gRPC | REST | GraphQL |
|---|---|---|---|
| Protocol | HTTP/2, binary | HTTP/1.1 or /2, text | HTTP/1.1 or /2, text |
| Schema enforcement | Protobuf (compile-time + runtime) | Optional (OpenAPI) | SDL (compile-time via codegen) |
| TypeScript codegen | Excellent (buf, ts-proto) | Good (openapi-typescript) | Good (graphql-codegen) |
| Streaming support | Native (4 modes) | Limited (SSE, no client stream) | Subscriptions (WebSocket) |
| Browser support | Requires grpc-web proxy | Native | Native |
| Payload size | Small (binary) | Larger (JSON) | Variable (query-shaped) |
| Debugging | Harder (binary, needs grpcurl) | Easy (curl, browser) | Medium (GraphiQL helps) |
| Error model | Rich status codes | HTTP status codes | Always 200, errors in body |
| Versioning | Field numbers, additive | URL versioning or content-type | Schema evolution |
| Learning curve | Steeper | Low | Medium |
| Best fit | Internal microservices, high RPC volume, streaming | Public APIs, browser clients, simple services | Aggregation layer, flexible querying |
GraphQL adds a useful aggregation layer when clients need flexible field selection across multiple underlying services. It does not make sense as the transport between those underlying services: the query flexibility that GraphQL provides between client and aggregator is overhead between two backend services with fixed contracts.
REST remains correct for public-facing APIs. Browser clients work with REST natively. Tooling (curl, Postman, browser devtools) understands HTTP semantics without plugins. The feedback loop during development is faster.
gRPC earns its complexity when: internal call volume is high enough that JSON parsing CPU shows up in profiling, you need streaming, or schema drift between services has already caused incidents.
Production Considerations
TLS between services. createInsecure() is fine for local development. In production, use mutual TLS or at minimum server-side TLS. With a service mesh, mTLS is handled transparently. Without one, generate certificates with cert-manager and load them at startup.
Health checking. Implement the gRPC health checking protocol (grpc.health.v1.Health). Kubernetes liveness and readiness probes support gRPC health checks natively since Kubernetes 1.24 using the grpc probe type.
import { HealthImplementation } from "grpc-health-check";
const healthImpl = new HealthImplementation({
"": proto.grpc.health.v1.HealthCheckResponse.ServingStatus.SERVING,
"user.v1.UserService":
proto.grpc.health.v1.HealthCheckResponse.ServingStatus.SERVING,
});
server.addService(HealthService, healthImpl.service);
Graceful shutdown. gRPC servers have in-flight streams that should drain before the process exits. Call server.tryShutdown() on SIGTERM, which waits for active RPCs to complete. Set a hard timeout and call server.forceShutdown() if draining takes too long.
process.on("SIGTERM", () => {
server.tryShutdown((err) => {
if (err) {
console.error("graceful shutdown failed", err);
server.forceShutdown();
}
process.exit(0);
});
// Force exit after 10s
setTimeout(() => {
server.forceShutdown();
process.exit(1);
}, 10_000);
});
Observability. gRPC request counts, error rates by status code, and latency percentiles are the three metrics that matter. opentelemetry-instrumentation-grpc auto-instruments @grpc/grpc-js for traces and metrics. Emit a log line per RPC with the method name, status code, and duration. Status code breakdown by method lets you detect when a specific RPC path starts returning UNAVAILABLE before it appears in aggregate error rates.
Channel management. Creating a new Client per request creates a new HTTP/2 connection per request. Create clients once at startup, reuse them across requests. For services that make many downstream gRPC calls, use a channel pool.
When REST Is Still the Right Choice
gRPC is not a universal upgrade. Use REST when:
- The service has browser clients. grpc-web adds a translation proxy that is one more thing to operate.
- The team is small and the contract surface is stable. The protobuf/codegen overhead is real setup time for simple cases.
- You need human-readable request/response logs in production. JSON in nginx access logs is searchable; binary protobuf frames are not.
- The service is consumed by external third parties. REST with OpenAPI is the standard they expect. gRPC as a public API creates a tooling and expertise burden for your consumers.
- Call volume is low and latency is not a concern. The serialization savings matter at scale, not at 10 RPS.
The decision is not binary. A common pattern is REST at the edge (browser-facing, public API surface) and gRPC between internal backend services where the properties of binary encoding and streaming matter.
gRPC’s value scales with the number of services calling each other and the volume of those calls. One service calling one other service over REST with a shared TypeScript type package is fine. Twenty services with hundreds of daily inter-service call types, schema drift, and streaming requirements is where gRPC pays for itself.
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.