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.