Free lesson · Node.js Deep Dive
gRPC in Node
Lesson 17.4 - gRPC in Node
This is reference material on gRPC - Google's RPC framework that has become the standard for service-to-service communication in modern microservice architectures.
The motivation: REST is great for public APIs and human-facing services, but for service-to-service traffic inside a system, REST's overhead is significant. JSON parsing is slow; HTTP/1.1 has poor connection efficiency; there's no built-in streaming model; client code is hand-written and easily drifts from the server. gRPC addresses each of these: binary serialization (protobuf), HTTP/2 transport with multiplexing, native streaming, and auto-generated client/server code from a shared schema.
gRPC's sweet spot is internal microservices. For public APIs consumed by browsers and third parties, REST or GraphQL is usually still right (gRPC has limited browser support). For service A in your cluster calling service B in your cluster, gRPC often wins on developer experience and runtime efficiency.
This lesson covers protobuf, code generation, the four call patterns (unary, server streaming, client streaming, bidirectional), and the Node-specific tooling.
Protocol Buffers (Protobuf)
The schema definition. A .proto file describes messages and services:
syntax = "proto3";
package readinglist.v1;
service ReadingListService {
rpc GetList(GetListRequest) returns (List);
rpc ListLists(ListListsRequest) returns (ListListsResponse);
rpc CreateList(CreateListRequest) returns (List);
rpc StreamListUpdates(StreamRequest) returns (stream ListUpdate);
}
message List {
string id = 1;
string name = 2;
string description = 3;
bool is_public = 4;
string owner_id = 5;
repeated string book_ids = 6;
int64 created_at = 7;
}
message GetListRequest {
string id = 1;
}
message ListListsRequest {
int32 limit = 1;
string cursor = 2;
}
message ListListsResponse {
repeated List lists = 1;
string next_cursor = 2;
}
message CreateListRequest {
string name = 1;
string description = 2;
bool is_public = 3;
}
message StreamRequest {
string user_id = 1;
}
message ListUpdate {
string list_id = 1;
enum UpdateType {
UPDATE_TYPE_UNSPECIFIED = 0;
UPDATE_TYPE_CREATED = 1;
UPDATE_TYPE_MODIFIED = 2;
UPDATE_TYPE_DELETED = 3;
}
UpdateType type = 2;
List list = 3;
}
The notation:
syntax = "proto3"- the protobuf version.service- RPC service definition.rpc Method(Request) returns (Response)- an RPC method.stream- streaming variant.message- a structured data type.repeated- array of values.- Numbers after
=- field tags used in wire encoding. Don't change them once published.
The encoding is binary and efficient - typically much smaller than equivalent JSON. Schema evolution rules let you add fields without breaking existing clients (use new tag numbers; never reuse old ones).
Code Generation
The schema is the contract; the server and clients both generate code from it. For Node, two main toolchains:
@grpc/proto-loader (Dynamic Loading)
Load the .proto at runtime; no code generation step:
const protoLoader = require('@grpc/proto-loader');
const grpc = require('@grpc/grpc-js');
const packageDefinition = protoLoader.loadSync('reading_list.proto', {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const proto = grpc.loadPackageDefinition(packageDefinition);
const ReadingListService = proto.readinglist.v1.ReadingListService;
Pros: no build step; faster iteration in development.
Cons: no TypeScript types; runtime parsing overhead at startup; errors discovered at runtime.
Static Code Generation
Generate TypeScript/JavaScript code from the proto at build time. The current standard tool is ts-proto or @bufbuild/protoc-gen-es.
# Using buf (modern toolchain)
buf generate
The generated code:
- Typed message classes with proper TypeScript types.
- Service interfaces.
- Client stubs.
The build step takes effort to set up but is worth it for non-trivial projects. The typed API catches errors at compile time; the developer experience matches the rest of your TypeScript code.
For new gRPC projects, use static code generation with buf. Dynamic loading is fine for prototypes.
A gRPC Server
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const packageDefinition = protoLoader.loadSync('reading_list.proto', { /* options */ });
const proto = grpc.loadPackageDefinition(packageDefinition);
const server = new grpc.Server();
server.addService(proto.readinglist.v1.ReadingListService.service, {
GetList: async (call, callback) => {
try {
const { id } = call.request;
const list = await db.findList(id);
if (!list) {
return callback({
code: grpc.status.NOT_FOUND,
message: 'List not found',
});
}
callback(null, list);
} catch (err) {
callback({ code: grpc.status.INTERNAL, message: err.message });
}
},
ListLists: async (call, callback) => {
const { limit, cursor } = call.request;
const result = await db.findLists({ limit, cursor });
callback(null, { lists: result.data, next_cursor: result.nextCursor });
},
CreateList: async (call, callback) => {
// ... extract auth from call.metadata; create list
const list = await db.createList(call.request);
callback(null, list);
},
StreamListUpdates: (call) => {
const { user_id } = call.request;
const subscription = subscribeToUpdates(user_id, (update) => {
call.write(update);
});
call.on('cancelled', () => {
subscription.unsubscribe();
});
},
});
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
console.log('gRPC server listening on :50051');
});
The handlers:
- Unary calls (
GetList,ListLists,CreateList) receive a call and a callback. - Server streaming (
StreamListUpdates) receives a call; usescall.write()to send messages; callscall.end()when done.
Authentication happens via the call's metadata - gRPC equivalent of HTTP headers:
GetList: async (call, callback) => {
const token = call.metadata.get('authorization')[0];
const user = await verifyToken(token);
if (!user) {
return callback({ code: grpc.status.UNAUTHENTICATED });
}
// ... proceed with authenticated user
},
For TLS, replace createInsecure() with a certificate-based credential. Production gRPC almost always uses TLS.
A gRPC Client
const client = new proto.readinglist.v1.ReadingListService(
'localhost:50051',
grpc.credentials.createInsecure()
);
// Unary call
client.GetList({ id: '42' }, (err, list) => {
if (err) {
if (err.code === grpc.status.NOT_FOUND) {
console.log('Not found');
return;
}
console.error('Error:', err);
return;
}
console.log('Got list:', list);
});
// With metadata
const metadata = new grpc.Metadata();
metadata.add('authorization', 'Bearer my-token');
client.CreateList(
{ name: 'My List', is_public: false },
metadata,
(err, list) => { /* ... */ }
);
// Server streaming
const stream = client.StreamListUpdates({ user_id: '42' });
stream.on('data', (update) => {
console.log('Update:', update);
});
stream.on('end', () => {
console.log('Stream ended');
});
stream.on('error', (err) => {
console.error('Stream error:', err);
});
For Promise-style usage, the gRPC client supports it via promise wrappers (util.promisify or @grpc/promise-grpc).
The Four Call Patterns
gRPC supports four interaction patterns:
flowchart TB
subgraph "Unary"
UC[Client] -->|1 message| US[Server]
US -->|1 message| UC
end
subgraph "Server Streaming"
SC[Client] -->|1 message| SS[Server]
SS -->|many messages| SC
end
subgraph "Client Streaming"
CC[Client] -->|many messages| CS[Server]
CS -->|1 message| CC
end
subgraph "Bidirectional Streaming"
BC[Client] <-->|many messages| BS[Server]
end
style UC fill:#a78bfa,stroke:#8b5cf6,color:#000
style SS fill:#fbbf24,stroke:#f59e0b,color:#000
style CC fill:#34d399,stroke:#10b981,color:#000
style BC fill:#f87171,stroke:#ef4444,color:#000
A diagram of the four gRPC call patterns. Unary is the most familiar - client sends one request, server sends one response, like a typical REST call. Server streaming sends one request and receives many responses, useful for subscriptions or long-running queries with incremental results. Client streaming sends many requests and receives one response, useful for uploads or batch operations. Bidirectional streaming has both sides sending and receiving multiple messages independently, useful for chat-like protocols or continuous data exchange. Each pattern uses HTTP/2 streams underneath; gRPC chooses the right pattern based on the .proto definition.
The proto declarations:
service Example {
rpc Unary(Request) returns (Response);
rpc ServerStreaming(Request) returns (stream Response);
rpc ClientStreaming(stream Request) returns (Response);
rpc BidiStreaming(stream Request) returns (stream Response);
}
The stream keyword indicates streaming on each side.
When to use each:
Unary. The default. Use when one request produces one response.
Server streaming. Subscriptions, live updates, large query results. Server pushes as data arrives.
Client streaming. Uploads, batch ingestion. Client pushes data; server processes and replies once.
Bidirectional. Chat, real-time collaboration, long-lived state synchronization. Both sides send independently.
Most gRPC services use unary calls almost exclusively. Streaming is for specific cases.
When to Choose gRPC
gRPC fits when:
Service-to-service inside a controlled environment. All clients and servers are yours; you control the proto files and deployment.
Performance matters. Binary serialization plus HTTP/2 connection reuse is significantly faster than REST for service-to-service traffic.
Strong typing matters. The schema is enforced; clients and servers can't drift.
Streaming is needed. Native streaming model is cleaner than WebSocket-on-top-of-HTTP.
You're polyglot. Code generation produces clients in many languages from one proto.
gRPC fits poorly when:
Public-facing API. Browser support is limited (gRPC-Web is workable but adds proxy infrastructure). Third-party consumers prefer REST.
Simple internal communication. For one service calling another with simple JSON payloads, REST with HTTP/2 keepalive is fine and operationally simpler.
Caching matters. REST integrates with HTTP caching; gRPC mostly doesn't.
Operational team is small. gRPC adds tooling - protoc, buf, generated code, service discovery, load balancing differences from REST. For one or two services, the overhead may not be justified.
For most Node teams: REST for external APIs, gRPC for high-traffic service-to-service. Many teams use REST for everything until performance specifically demands gRPC.
buf and Modern Tooling
buf is the modern protobuf toolchain. It replaces the older protoc-based workflows with much better developer experience:
# Install buf
npm install -g @bufbuild/buf
# Initialize a buf module
buf mod init
# Generate code (configured via buf.gen.yaml)
buf generate
# Lint your protos
buf lint
# Check breaking changes
buf breaking --against '.git#branch=main'
buf.gen.yaml configures code generation:
version: v2
plugins:
- remote: buf.build/bufbuild/es
out: src/gen
opt:
- target=ts
- remote: buf.build/connectrpc/es
out: src/gen
The buf toolchain also provides:
- Schema registry - central place to manage proto files across services.
- Breaking change detection - CI can fail PRs that introduce breaking proto changes.
- Connect - modern alternative to gRPC with HTTP/JSON compatibility (works in browsers).
For new gRPC work in 2026, start with buf. The older protoc-based workflows still work but require more boilerplate.
Connect: gRPC for the Browser
Connect (from buf) is a modern protocol that lets gRPC-style services run over HTTP/1.1 with JSON. It uses the same proto files but works in browsers without proxies.
import { createConnectTransport } from '@connectrpc/connect-web';
import { createPromiseClient } from '@connectrpc/connect';
import { ReadingListService } from './gen/reading_list_connect.js';
const transport = createConnectTransport({ baseUrl: 'https://api.example.com' });
const client = createPromiseClient(ReadingListService, transport);
const list = await client.getList({ id: '42' });
Same proto file; works in browsers; HTTP-compatible (cacheable, observable).
For new services that want gRPC's developer experience and protocol benefits but also need browser support, Connect is worth evaluating. It's an evolution of gRPC, not a replacement.
A Comparison Table
| Concern | REST | GraphQL | gRPC |
|---|---|---|---|
| Transport | HTTP/1.1 or HTTP/2 | HTTP/1.1 or HTTP/2 | HTTP/2 |
| Encoding | JSON | JSON | Protobuf (binary) |
| Schema | Optional (OpenAPI) | Required (SDL) | Required (proto) |
| Code gen | Optional (from OpenAPI) | Optional (typed clients) | Standard |
| Streaming | Server-sent events / WebSocket | Subscriptions (WebSocket) | Native (4 patterns) |
| Browser support | Excellent | Excellent | Limited (via Connect) |
| Public API fit | Excellent | Good | Poor |
| Internal microservices | Good | Good | Excellent |
| Caching | HTTP caching | App-level | App-level |
The right choice depends on the workload. Most services use REST. Service-to-service in performance-sensitive systems uses gRPC. Data-heavy multi-client APIs sometimes use GraphQL.
What to Take From This Lesson
- gRPC fits service-to-service traffic in performance-sensitive systems.
- Proto files are the contract; code generation produces typed clients/servers.
- Four call patterns: unary, server streaming, client streaming, bidirectional.
- Authentication via metadata (gRPC headers); TLS via credentials.
- buf is the modern toolchain; Connect extends gRPC to browsers.
- For public APIs, prefer REST or GraphQL; gRPC has limited browser support.
Try It
Exercise: Warmup (30 minutes). Define a small
.protofile (User service with Get and List methods). Implement a Node server and a Node client using@grpc/grpc-js. Verify communication.
Exercise: Core (40 minutes). Add a server-streaming method to the service. Implement a long-running subscription pattern. Test cancellation by client disconnect.
Exercise: Core (30 minutes). Set up
bufwith a project. Generate TypeScript code. Compare developer experience to the dynamic-loading approach.
Exercise: Stretch (60 minutes). Implement the same service via REST and gRPC. Benchmark both with realistic workload. Compare throughput, latency, payload sizes.
What's Next
Lesson 17.5 covers API versioning - the discipline that lets you evolve an API without breaking existing clients.