gRPC in Node.js Microservices
gRPC is a high-performance remote procedure call framework that lets services talk to each other through strongly typed contracts rather than ad-hoc JSON over HTTP. Instead of guessing at payload shapes, you declare your service interface once in a .proto file, and both client and server share that schema. Because gRPC rides on HTTP/2 and serializes data with Protocol Buffers (a compact binary format), it is dramatically faster and lighter than REST for internal service-to-service traffic — making it a natural fit for the hot paths inside a microservices mesh.
Why gRPC for internal calls
In a microservices system, the chatter between services often dwarfs the traffic from end users. Every extra millisecond of latency and every wasted byte multiplies across thousands of internal calls. gRPC optimizes exactly this layer.
| Aspect | REST / JSON | gRPC / Protobuf |
|---|---|---|
| Wire format | Text (JSON) | Compact binary |
| Transport | HTTP/1.1 (usually) | HTTP/2 (multiplexed) |
| Contract | Optional (OpenAPI) | Mandatory (.proto) |
| Streaming | Limited | First-class, bidirectional |
| Codegen | Optional | Built-in across languages |
Because the contract is mandatory, breaking changes are caught at the schema level, and the same .proto generates clients in Go, Java, Python, or Node.js — ideal for polyglot environments.
gRPC excels for east-west (service-to-service) traffic. For north-south (browser-facing) APIs, plain REST or GraphQL is usually friendlier, since browsers cannot speak raw gRPC without a proxy like gRPC-Web.
Installing the tooling
The modern, pure-JavaScript implementation is @grpc/grpc-js (no native compilation required). Pair it with @grpc/proto-loader to load .proto files dynamically at runtime.
npm install @grpc/grpc-js @grpc/proto-loader
Defining a service with Protocol Buffers
A .proto file describes your messages and the RPC methods a service exposes. Create proto/orders.proto:
syntax = "proto3";
package orders;
service OrderService {
rpc GetOrder (OrderRequest) returns (Order);
rpc WatchOrder (OrderRequest) returns (stream OrderEvent);
}
message OrderRequest {
string order_id = 1;
}
message Order {
string order_id = 1;
string status = 2;
double total = 3;
}
message OrderEvent {
string status = 1;
int64 timestamp = 2;
}
The numbered field tags (= 1, = 2) are the wire identifiers — they must never change once in production, even if you rename a field.
Loading the contract at runtime
Rather than pre-generating stubs, @grpc/proto-loader reads the .proto and hands back a typed package definition. This keeps the build simple while still enforcing the schema.
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageDef = protoLoader.loadSync(
path.join(__dirname, "proto/orders.proto"),
{ keepCase: true, longs: String, enums: String, defaults: true }
);
export const ordersProto = grpc.loadPackageDefinition(packageDef).orders;
Using CommonJS? Swap the import lines for
const grpc = require("@grpc/grpc-js")andconst protoLoader = require("@grpc/proto-loader"), and use__dirnamedirectly.
Implementing the server
Each RPC method becomes a handler. The first argument is the call (with .request), and for unary calls you respond through a Node-style (error, response) callback.
import * as grpc from "@grpc/grpc-js";
import { ordersProto } from "./load-proto.js";
const orders = new Map([
["A-1001", { order_id: "A-1001", status: "PAID", total: 49.9 }],
]);
function getOrder(call, callback) {
const order = orders.get(call.request.order_id);
if (!order) {
return callback({ code: grpc.status.NOT_FOUND, details: "Order not found" });
}
callback(null, order);
}
// Server streaming: push multiple events, then end the stream.
function watchOrder(call) {
const stages = ["PACKED", "SHIPPED", "DELIVERED"];
let i = 0;
const timer = setInterval(() => {
if (i >= stages.length) {
clearInterval(timer);
return call.end();
}
call.write({ status: stages[i++], timestamp: Date.now() });
}, 1000);
}
const server = new grpc.Server();
server.addService(ordersProto.OrderService.service, {
GetOrder: getOrder,
WatchOrder: watchOrder,
});
server.bindAsync(
"0.0.0.0:50051",
grpc.ServerCredentials.createInsecure(),
() => {
console.log("OrderService listening on :50051");
}
);
Output:
OrderService listening on :50051
Calling from a client
A client stub mirrors the service methods. Unary calls take a request plus a callback; streaming calls return an emitter you read with .on("data", ...).
import * as grpc from "@grpc/grpc-js";
import { ordersProto } from "./load-proto.js";
const client = new ordersProto.OrderService(
"localhost:50051",
grpc.credentials.createInsecure()
);
// Unary RPC
client.GetOrder({ order_id: "A-1001" }, (err, order) => {
if (err) return console.error(err.details);
console.log("Order:", order);
});
// Server-streaming RPC
const stream = client.WatchOrder({ order_id: "A-1001" });
stream.on("data", (event) => console.log("Event:", event.status));
stream.on("end", () => console.log("Stream closed"));
Output:
Order: { order_id: 'A-1001', status: 'PAID', total: 49.9 }
Event: PACKED
Event: SHIPPED
Event: DELIVERED
Stream closed
The four RPC types
gRPC supports four interaction patterns, all declared with the stream keyword in the .proto:
| Pattern | Signature in .proto | Use case |
|---|---|---|
| Unary | (Req) returns (Res) | Standard request/response |
| Server streaming | (Req) returns (stream Res) | Live updates, feeds |
| Client streaming | (stream Req) returns (Res) | Uploads, batched ingest |
| Bidirectional | (stream Req) returns (stream Res) | Chat, real-time sync |
Code generation versus dynamic loading
The dynamic proto-loader approach shown above is great for quick iteration. For larger teams that want compile-time TypeScript types, generate static stubs with protoc and ts-proto (or grpc-tools). Generated code gives editor autocompletion and catches contract drift before runtime, at the cost of a build step.
npx protoc --plugin=protoc-gen-ts_proto \
--ts_proto_out=./generated proto/orders.proto
Best practices
- Treat
.protofiles as the source of truth and version them in a shared repo so every service consumes the same contract. - Never reuse or renumber existing field tags; add new optional fields instead to preserve backward compatibility.
- Always set deadlines on client calls (
{ deadline: Date.now() + 2000 }) so a slow downstream service cannot stall the caller. - Use TLS credentials (
grpc.ServerCredentials.createSsl) in production —createInsecureis for local development only. - Map domain errors to proper
grpc.statuscodes (NOT_FOUND,INVALID_ARGUMENT) rather than throwing generic errors. - Prefer streaming RPCs over polling for live data; HTTP/2 multiplexing makes long-lived streams cheap.
- Add interceptors for cross-cutting concerns like auth tokens, retries, and tracing metadata.