Skip to content

gRPC Services in NestJS

Aman Kumar Singh8 min read
Part 35 of 40From the NestJS Production Guide series
gRPC Services in NestJS — article by Aman Kumar Singh

Last time in the NestJS Production Guide, I covered Microservices with NestJS, where the transport layer between services was mostly an afterthought: pick TCP or a message broker, wire up ClientsModule, move on. gRPC is different. That transport choice stops being an afterthought and starts shaping how your services talk to each other, what your contracts look like, and how much latency you pay per call.

I want to be upfront about scope. Most internal services in a typical SaaS backend do fine over plain HTTP or a queue. gRPC earns its place when you have high-frequency internal calls between services you control, a schema that changes often enough that a typed contract saves real time, or a latency budget tight enough that HTTP/1.1 overhead and JSON parsing start showing up in profiles. If none of that applies yet, this article is worth reading for the concepts, but not necessarily worth adopting today.

Why gRPC changes the shape of your services

REST between internal services usually means: define a DTO, validate it with class-validator, serialize to JSON, send it over HTTP/1.1, parse it back on the other end. It works, and NestJS makes it pleasant. The cost is that the contract lives in your code, not in a language-neutral definition, so two services written by two different teams can drift out of sync in ways TypeScript alone won't catch until runtime.

gRPC flips that. The contract is a .proto file: a schema that describes every service, every method, every message field, with explicit types and field numbers. Both sides generate code from the same file. A breaking change in a message shape becomes a compile error in the client, not a 500 in production. That's the real value of gRPC in an internal architecture: it makes your service contracts a first-class, versionable artifact instead of an implicit agreement between two interface definitions that happen to live in different repos.

On top of that, gRPC runs over HTTP/2, which multiplexes many calls over a single TCP connection and supports proper server-side and bidirectional streaming. Protocol Buffers serialize to a compact binary format instead of text-based JSON, which reduces payload size and parsing cost. None of this makes gRPC universally faster than REST for every workload, and I'm not going to pretend I have head-to-head numbers to hand you. What I can say with confidence: the overhead reduction is real. It comes from removing steps (JSON stringify/parse, HTTP/1.1 connection churn), not from some magic in the wire format.

The tradeoff is tooling and debuggability. You can't curl a gRPC endpoint the way you curl a REST one. Browser clients need a proxy layer like grpc-web. Your API gateway, load balancer, and observability stack all need to understand HTTP/2 and gRPC status codes, which not every piece of infrastructure does out of the box. Reach for gRPC on internal, service-to-service calls where you control both ends. Keep REST or GraphQL at the edge, where browsers and third parties are the clients.

Building a gRPC service in NestJS

NestJS treats gRPC as a microservice transport, the same family as TCP and Redis transports from the previous article, just with a stricter contract. Start with the proto definition.

// proto/billing.proto
syntax = "proto3";

package billing;

service BillingService {
  rpc GetInvoice (GetInvoiceRequest) returns (Invoice);
  rpc ListInvoicesForCustomer (ListInvoicesRequest) returns (stream Invoice);
}

message GetInvoiceRequest {
  string invoiceId = 1;
}

message ListInvoicesRequest {
  string customerId = 1;
}

message Invoice {
  string id = 1;
  string customerId = 2;
  int64 amountCents = 3;
  string status = 4;
}

Install the packages and point the microservice at the proto file.

npm install @grpc/grpc-js @grpc/proto-loader @nestjs/microservices
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { Transport, MicroserviceOptions } from '@nestjs/microservices';
import { join } from 'path';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    transport: Transport.GRPC,
    options: {
      package: 'billing',
      protoPath: join(__dirname, '../proto/billing.proto'),
      url: '0.0.0.0:5000',
    },
  });

  await app.listen();
}

bootstrap();

The controller implements the RPC methods using @GrpcMethod, which matches on the service and method names from the proto file.

// src/billing/billing.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { BillingService } from './billing.service';

@Controller()
export class BillingController {
  constructor(private readonly billingService: BillingService) {}

  @GrpcMethod('BillingService', 'GetInvoice')
  async getInvoice(data: { invoiceId: string }) {
    return this.billingService.findById(data.invoiceId);
  }
}

On the calling side, another Nest service consumes this over ClientsModule, gets a typed client, and calls it like any injected dependency.

// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { join } from 'path';

@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'BILLING_PACKAGE',
        transport: Transport.GRPC,
        options: {
          package: 'billing',
          protoPath: join(__dirname, '../../proto/billing.proto'),
          url: 'billing-service:5000',
        },
      },
    ]),
  ],
})
export class OrdersModule {}
// src/orders/orders.service.ts
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { firstValueFrom, Observable } from 'rxjs';

interface BillingServiceClient {
  getInvoice(data: { invoiceId: string }): Observable<{ id: string; amountCents: number }>;
}

@Injectable()
export class OrdersService implements OnModuleInit {
  private billingClient: BillingServiceClient;

  constructor(@Inject('BILLING_PACKAGE') private readonly client: ClientGrpc) {}

  onModuleInit() {
    this.billingClient = this.client.getService<BillingServiceClient>('BillingService');
  }

  async getInvoiceTotal(invoiceId: string): Promise<number> {
    const invoice = await firstValueFrom(this.billingClient.getInvoice({ invoiceId }));
    return invoice.amountCents;
  }
}

That BillingServiceClient interface is worth writing out by hand or generating with a tool like ts-proto. Without it, the generated client is typed as any, and you lose the exact benefit that made you reach for gRPC in the first place.

Streaming, deadlines, and error handling

The ListInvoicesForCustomer method in the proto file returns stream Invoice, which NestJS surfaces through @GrpcStreamMethod on the server side and an Observable on the client side. Server streaming is useful when a single request produces a sequence of results you want to start consuming before the whole set is ready, such as paginating through invoices without a separate cursor-based endpoint.

// src/billing/billing.controller.ts
import { GrpcStreamMethod } from '@nestjs/microservices';
import { Observable, Subject } from 'rxjs';

@GrpcStreamMethod('BillingService', 'ListInvoicesForCustomer')
listInvoicesForCustomer(data: { customerId: string }): Observable<any> {
  const subject = new Subject();

  this.billingService.streamInvoices(data.customerId, (invoice) => subject.next(invoice))
    .then(() => subject.complete())
    .catch((err) => subject.error(err));

  return subject.asObservable();
}

Error handling in gRPC does not map to HTTP status codes. It uses its own status codes (NOT_FOUND, INVALID_ARGUMENT, DEADLINE_EXCEEDED, and so on), and NestJS exposes this through RpcException.

// src/billing/billing.service.ts
import { Injectable } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { status } from '@grpc/grpc-js';

@Injectable()
export class BillingService {
  async findById(invoiceId: string) {
    const invoice = await this.repository.findOne(invoiceId);
    if (!invoice) {
      throw new RpcException({
        code: status.NOT_FOUND,
        message: `Invoice ${invoiceId} not found`,
      });
    }
    return invoice;
  }
}

Set deadlines on the client rather than relying on defaults. An internal call with no deadline can hang a request thread waiting on a downstream service that's stuck, and that failure mode is far harder to diagnose than a clean DEADLINE_EXCEEDED. Most gRPC client libraries accept a deadline per call; treat it the same way you'd treat a timeout on an HTTP client, sized to what the caller can actually tolerate.

Production pitfalls worth planning for

Proto files are your contract, and contracts need versioning discipline. Never reuse or renumber a field once it has shipped; protobuf's binary encoding depends on field numbers staying stable, and reusing one can silently corrupt data for a client still running an old build. Add new fields with new numbers, mark deprecated ones as reserved, and keep the proto files in a shared package or repo that both sides pull from, rather than copy-pasted between services.

Load balancing is a real decision, not a default. A naive gRPC client opens one HTTP/2 connection and multiplexes every call over it. That means a simple round-robin DNS setup in front of multiple server instances won't spread load the way it does for short-lived HTTP/1.1 connections. In Kubernetes, this usually means either a client-side load balancing policy (grpc.lb_policy set to round_robin with a headless service so the client sees every pod IP) or a proxy that understands HTTP/2, like Envoy or a service mesh sidecar. Skipping this step is the single most common way I've seen gRPC deployments end up with all traffic pinned to one pod.

Health checking deserves the same attention you'd give any other transport. gRPC has a standard health-checking protocol (grpc.health.v1.Health), and wiring it up lets your orchestrator and load balancer make real decisions instead of assuming a service is healthy because the process is running. Pair it with the readiness and liveness patterns from the health checks article in this series rather than inventing a bespoke check per service.

Finally, observability needs deliberate setup. Standard HTTP tracing middleware won't see gRPC calls; you need interceptors on both client and server that propagate trace context (typically through gRPC metadata) into whatever tracing system you use. Add this from day one. Retrofitting distributed tracing onto an existing mesh of gRPC services, once you actually need to debug a cross-service latency spike, is a much worse time to build it.

Key takeaways

  • gRPC's real value in NestJS is a typed, versionable contract defined in `.proto` files, shared between services instead of implicitly agreed upon.
  • Use `Transport.GRPC` with `@GrpcMethod` on the server and `ClientGrpc` with a hand-written or generated interface on the client to keep type safety end to end.
  • Server streaming with `@GrpcStreamMethod` is useful for sequences of results a client can start consuming before the full set is ready.
  • Errors use gRPC status codes through `RpcException`, not HTTP status codes; set explicit deadlines on every client call.
  • Never reuse a field number in a proto file once it has shipped; add new fields with new numbers and mark old ones reserved.
  • Plan load balancing and health checking explicitly. A naive HTTP/2 connection reuse pattern can pin all traffic to one pod without a client-side policy or an HTTP/2-aware proxy.

Frequently asked questions

Is gRPC faster than REST in NestJS?

It generally has less overhead per call because of binary serialization and HTTP/2 multiplexing, but I don't have specific benchmark numbers to share, and the actual gain depends heavily on payload size, network conditions, and your existing REST implementation. Treat it as a reduction in overhead, not a guaranteed speedup for your workload.

Should I use gRPC for a public API?

Usually not directly. Browsers can't call gRPC natively without a proxy layer like grpc-web, and most third-party API consumers expect REST or GraphQL. Keep gRPC for internal service-to-service calls and expose a REST or GraphQL layer at the edge.

Can I generate TypeScript types from my proto files automatically?

Yes, tools like `ts-proto` generate TypeScript interfaces and even NestJS-compatible client/server code directly from `.proto` files, which removes the manual step of writing client interfaces by hand and keeps them in sync with the schema.

How do I handle backward compatibility when a proto message changes?

Add new fields with new, unused field numbers and never remove or renumber existing ones. Mark retired fields as `reserved` so nobody accidentally reuses their number. Protobuf is designed so old and new clients can both read messages that have extra or missing optional fields.

Does NestJS support bidirectional streaming with gRPC?

Yes, through `@GrpcStreamMethod`, using RxJS `Observable` and `Subject` on both sides to model a continuous exchange of messages. It's a more complex programming model than unary or server streaming, so reserve it for cases with a genuine ongoing exchange, like a live feed, rather than defaulting to it.

Do I need a service mesh to use gRPC in production?

No, but you do need something that understands HTTP/2 for load balancing, whether that's a service mesh sidecar, an HTTP/2-aware proxy like Envoy, or a client-side load balancing policy pointed at a headless Kubernetes service. Skipping this step is the most common way gRPC deployments end up with uneven load distribution.

Related articles

A NestJS Production Checklist — Aman Kumar Singh
Event-Driven Architecture — Aman Kumar Singh
CQRS in NestJS — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.