Skip to content
Node.js nd microservices 5 min read

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.

AspectREST / JSONgRPC / Protobuf
Wire formatText (JSON)Compact binary
TransportHTTP/1.1 (usually)HTTP/2 (multiplexed)
ContractOptional (OpenAPI)Mandatory (.proto)
StreamingLimitedFirst-class, bidirectional
CodegenOptionalBuilt-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") and const protoLoader = require("@grpc/proto-loader"), and use __dirname directly.

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:

PatternSignature in .protoUse 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 .proto files 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 — createInsecure is for local development only.
  • Map domain errors to proper grpc.status codes (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.
Last updated June 14, 2026
Was this helpful?