mdashikjs/blog
All posts
gRPC for Node.js Microservice Communication
Backend

gRPC for Node.js Microservice Communication

Backend5 min

gRPC for Node.js Microservice Communication

REST is fine for public APIs. But for internal service-to-service calls where latency and payload size matter, gRPC with Protocol Buffers is measurably faster and catches contract drift at compile time.

gRPCMicroservicesNode.jsProtocol Buffers
Share:

Why gRPC over REST internally

  1. Binary serialization: Protocol Buffers are 3-10x smaller than JSON for the same data
  2. HTTP/2: multiplexed streams, header compression, bidirectional communication
  3. Code generation: the .proto file generates typed clients and servers — no OpenAPI codegen pipeline
  4. Streaming: server-streaming, client-streaming, and bidirectional streaming are first-class

Define the contract

syntax = "proto3";

package users;

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (stream User);
}

message GetUserRequest {
  string id = 1;
}

message ListUsersRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message User {
  string id = 1;
  string email = 2;
  string name = 3;
  string role = 4;
}

This is the single source of truth. Both client and server are generated from it.

Server implementation

import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';

const packageDef = protoLoader.loadSync('users.proto', {
  keepCase: true,
  longs: String,
  defaults: true,
});
const proto = grpc.loadPackageDefinition(packageDef) as any;

const server = new grpc.Server();
server.addService(proto.users.UserService.service, {
  getUser: async (call, callback) => {
    const user = await db.user.findUnique({ where: { id: call.request.id } });
    if (!user) {
      return callback({ code: grpc.status.NOT_FOUND, message: 'User not found' });
    }
    callback(null, user);
  },
  listUsers: async (call) => {
    const users = await db.user.findMany({ take: call.request.page_size });
    for (const user of users) {
      call.write(user);
    }
    call.end();
  },
});

server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
  console.log('gRPC server running on :50051');
});

Client usage

const client = new proto.users.UserService(
  'user-service:50051',
  grpc.credentials.createInsecure(),
);

// Unary call
client.getUser({ id: 'user_123' }, (err, user) => {
  if (err) throw err;
  console.log(user.email);
});

// Server streaming
const stream = client.listUsers({ page_size: 100 });
stream.on('data', (user) => console.log(user.name));
stream.on('end', () => console.log('Done'));

Error handling with status codes

gRPC has its own status codes — NOT_FOUND, PERMISSION_DENIED, DEADLINE_EXCEEDED, etc. Map them consistently:

const GRPC_TO_HTTP: Record<number, number> = {
  [grpc.status.NOT_FOUND]: 404,
  [grpc.status.PERMISSION_DENIED]: 403,
  [grpc.status.INVALID_ARGUMENT]: 400,
  [grpc.status.DEADLINE_EXCEEDED]: 504,
};

Deadlines, not timeouts

gRPC uses deadlines — an absolute point in time, not a duration. This is better for chains of services because the deadline propagates. If service A gives service B a 5-second deadline, and B calls C, C inherits the remaining time. No one accidentally waits longer than the original caller intended.

const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 5);
client.getUser({ id: 'user_123' }, { deadline }, callback);

When to stay with REST

  • Public APIs: gRPC needs client-side code generation. REST is universally accessible.
  • Browser clients: gRPC-Web exists but adds complexity. REST or tRPC is simpler.
  • Small teams with few services: the proto toolchain has overhead. If you have three services, REST with TypeScript shared types is enough.
MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.