AlgoInsight

Free lesson · Node.js Deep Dive

gRPC in Node

Medium8 min readnodejsjavascriptbackend

Lesson 17.4 - gRPC in Node

This is reference material on gRPC - Google's RPC framework that has become the standard for service-to-service communication in modern microservice architectures.

The motivation: REST is great for public APIs and human-facing services, but for service-to-service traffic inside a system, REST's overhead is significant. JSON parsing is slow; HTTP/1.1 has poor connection efficiency; there's no built-in streaming model; client code is hand-written and easily drifts from the server. gRPC addresses each of these: binary serialization (protobuf), HTTP/2 transport with multiplexing, native streaming, and auto-generated client/server code from a shared schema.

gRPC's sweet spot is internal microservices. For public APIs consumed by browsers and third parties, REST or GraphQL is usually still right (gRPC has limited browser support). For service A in your cluster calling service B in your cluster, gRPC often wins on developer experience and runtime efficiency.

This lesson covers protobuf, code generation, the four call patterns (unary, server streaming, client streaming, bidirectional), and the Node-specific tooling.


Protocol Buffers (Protobuf)

The schema definition. A .proto file describes messages and services:

syntax = "proto3";

package readinglist.v1;

service ReadingListService {
  rpc GetList(GetListRequest) returns (List);
  rpc ListLists(ListListsRequest) returns (ListListsResponse);
  rpc CreateList(CreateListRequest) returns (List);
  rpc StreamListUpdates(StreamRequest) returns (stream ListUpdate);
}

message List {
  string id = 1;
  string name = 2;
  string description = 3;
  bool is_public = 4;
  string owner_id = 5;
  repeated string book_ids = 6;
  int64 created_at = 7;
}

message GetListRequest {
  string id = 1;
}

message ListListsRequest {
  int32 limit = 1;
  string cursor = 2;
}

message ListListsResponse {
  repeated List lists = 1;
  string next_cursor = 2;
}

message CreateListRequest {
  string name = 1;
  string description = 2;
  bool is_public = 3;
}

message StreamRequest {
  string user_id = 1;
}

message ListUpdate {
  string list_id = 1;
  enum UpdateType {
    UPDATE_TYPE_UNSPECIFIED = 0;
    UPDATE_TYPE_CREATED = 1;
    UPDATE_TYPE_MODIFIED = 2;
    UPDATE_TYPE_DELETED = 3;
  }
  UpdateType type = 2;
  List list = 3;
}

The notation:

The encoding is binary and efficient - typically much smaller than equivalent JSON. Schema evolution rules let you add fields without breaking existing clients (use new tag numbers; never reuse old ones).


Code Generation

The schema is the contract; the server and clients both generate code from it. For Node, two main toolchains:

@grpc/proto-loader (Dynamic Loading)

Load the .proto at runtime; no code generation step:

const protoLoader = require('@grpc/proto-loader');
const grpc = require('@grpc/grpc-js');

const packageDefinition = protoLoader.loadSync('reading_list.proto', {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});

const proto = grpc.loadPackageDefinition(packageDefinition);
const ReadingListService = proto.readinglist.v1.ReadingListService;

Pros: no build step; faster iteration in development.

Cons: no TypeScript types; runtime parsing overhead at startup; errors discovered at runtime.

Static Code Generation

Generate TypeScript/JavaScript code from the proto at build time. The current standard tool is ts-proto or @bufbuild/protoc-gen-es.

# Using buf (modern toolchain)
buf generate

The generated code:

The build step takes effort to set up but is worth it for non-trivial projects. The typed API catches errors at compile time; the developer experience matches the rest of your TypeScript code.

For new gRPC projects, use static code generation with buf. Dynamic loading is fine for prototypes.


A gRPC Server

const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

const packageDefinition = protoLoader.loadSync('reading_list.proto', { /* options */ });
const proto = grpc.loadPackageDefinition(packageDefinition);

const server = new grpc.Server();

server.addService(proto.readinglist.v1.ReadingListService.service, {
  GetList: async (call, callback) => {
    try {
      const { id } = call.request;
      const list = await db.findList(id);
      if (!list) {
        return callback({
          code: grpc.status.NOT_FOUND,
          message: 'List not found',
        });
      }
      callback(null, list);
    } catch (err) {
      callback({ code: grpc.status.INTERNAL, message: err.message });
    }
  },
  
  ListLists: async (call, callback) => {
    const { limit, cursor } = call.request;
    const result = await db.findLists({ limit, cursor });
    callback(null, { lists: result.data, next_cursor: result.nextCursor });
  },
  
  CreateList: async (call, callback) => {
    // ... extract auth from call.metadata; create list
    const list = await db.createList(call.request);
    callback(null, list);
  },
  
  StreamListUpdates: (call) => {
    const { user_id } = call.request;
    
    const subscription = subscribeToUpdates(user_id, (update) => {
      call.write(update);
    });
    
    call.on('cancelled', () => {
      subscription.unsubscribe();
    });
  },
});

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

The handlers:

Authentication happens via the call's metadata - gRPC equivalent of HTTP headers:

GetList: async (call, callback) => {
  const token = call.metadata.get('authorization')[0];
  const user = await verifyToken(token);
  if (!user) {
    return callback({ code: grpc.status.UNAUTHENTICATED });
  }
  // ... proceed with authenticated user
},

For TLS, replace createInsecure() with a certificate-based credential. Production gRPC almost always uses TLS.


A gRPC Client

const client = new proto.readinglist.v1.ReadingListService(
  'localhost:50051',
  grpc.credentials.createInsecure()
);

// Unary call
client.GetList({ id: '42' }, (err, list) => {
  if (err) {
    if (err.code === grpc.status.NOT_FOUND) {
      console.log('Not found');
      return;
    }
    console.error('Error:', err);
    return;
  }
  console.log('Got list:', list);
});

// With metadata
const metadata = new grpc.Metadata();
metadata.add('authorization', 'Bearer my-token');
client.CreateList(
  { name: 'My List', is_public: false },
  metadata,
  (err, list) => { /* ... */ }
);

// Server streaming
const stream = client.StreamListUpdates({ user_id: '42' });
stream.on('data', (update) => {
  console.log('Update:', update);
});
stream.on('end', () => {
  console.log('Stream ended');
});
stream.on('error', (err) => {
  console.error('Stream error:', err);
});

For Promise-style usage, the gRPC client supports it via promise wrappers (util.promisify or @grpc/promise-grpc).


The Four Call Patterns

gRPC supports four interaction patterns:

flowchart TB
    subgraph "Unary"
        UC[Client] -->|1 message| US[Server]
        US -->|1 message| UC
    end

    subgraph "Server Streaming"
        SC[Client] -->|1 message| SS[Server]
        SS -->|many messages| SC
    end

    subgraph "Client Streaming"
        CC[Client] -->|many messages| CS[Server]
        CS -->|1 message| CC
    end

    subgraph "Bidirectional Streaming"
        BC[Client] <-->|many messages| BS[Server]
    end

    style UC fill:#a78bfa,stroke:#8b5cf6,color:#000
    style SS fill:#fbbf24,stroke:#f59e0b,color:#000
    style CC fill:#34d399,stroke:#10b981,color:#000
    style BC fill:#f87171,stroke:#ef4444,color:#000
A diagram of the four gRPC call patterns. Unary is the most familiar - client sends one request, server sends one response, like a typical REST call. Server streaming sends one request and receives many responses, useful for subscriptions or long-running queries with incremental results. Client streaming sends many requests and receives one response, useful for uploads or batch operations. Bidirectional streaming has both sides sending and receiving multiple messages independently, useful for chat-like protocols or continuous data exchange. Each pattern uses HTTP/2 streams underneath; gRPC chooses the right pattern based on the .proto definition.

The proto declarations:

service Example {
  rpc Unary(Request) returns (Response);
  rpc ServerStreaming(Request) returns (stream Response);
  rpc ClientStreaming(stream Request) returns (Response);
  rpc BidiStreaming(stream Request) returns (stream Response);
}

The stream keyword indicates streaming on each side.

When to use each:

Unary. The default. Use when one request produces one response.

Server streaming. Subscriptions, live updates, large query results. Server pushes as data arrives.

Client streaming. Uploads, batch ingestion. Client pushes data; server processes and replies once.

Bidirectional. Chat, real-time collaboration, long-lived state synchronization. Both sides send independently.

Most gRPC services use unary calls almost exclusively. Streaming is for specific cases.


When to Choose gRPC

gRPC fits when:

Service-to-service inside a controlled environment. All clients and servers are yours; you control the proto files and deployment.

Performance matters. Binary serialization plus HTTP/2 connection reuse is significantly faster than REST for service-to-service traffic.

Strong typing matters. The schema is enforced; clients and servers can't drift.

Streaming is needed. Native streaming model is cleaner than WebSocket-on-top-of-HTTP.

You're polyglot. Code generation produces clients in many languages from one proto.

gRPC fits poorly when:

Public-facing API. Browser support is limited (gRPC-Web is workable but adds proxy infrastructure). Third-party consumers prefer REST.

Simple internal communication. For one service calling another with simple JSON payloads, REST with HTTP/2 keepalive is fine and operationally simpler.

Caching matters. REST integrates with HTTP caching; gRPC mostly doesn't.

Operational team is small. gRPC adds tooling - protoc, buf, generated code, service discovery, load balancing differences from REST. For one or two services, the overhead may not be justified.

For most Node teams: REST for external APIs, gRPC for high-traffic service-to-service. Many teams use REST for everything until performance specifically demands gRPC.


buf and Modern Tooling

buf is the modern protobuf toolchain. It replaces the older protoc-based workflows with much better developer experience:

# Install buf
npm install -g @bufbuild/buf

# Initialize a buf module
buf mod init

# Generate code (configured via buf.gen.yaml)
buf generate

# Lint your protos
buf lint

# Check breaking changes
buf breaking --against '.git#branch=main'

buf.gen.yaml configures code generation:

version: v2
plugins:
  - remote: buf.build/bufbuild/es
    out: src/gen
    opt:
      - target=ts
  - remote: buf.build/connectrpc/es
    out: src/gen

The buf toolchain also provides:

For new gRPC work in 2026, start with buf. The older protoc-based workflows still work but require more boilerplate.


Connect: gRPC for the Browser

Connect (from buf) is a modern protocol that lets gRPC-style services run over HTTP/1.1 with JSON. It uses the same proto files but works in browsers without proxies.

import { createConnectTransport } from '@connectrpc/connect-web';
import { createPromiseClient } from '@connectrpc/connect';
import { ReadingListService } from './gen/reading_list_connect.js';

const transport = createConnectTransport({ baseUrl: 'https://api.example.com' });
const client = createPromiseClient(ReadingListService, transport);

const list = await client.getList({ id: '42' });

Same proto file; works in browsers; HTTP-compatible (cacheable, observable).

For new services that want gRPC's developer experience and protocol benefits but also need browser support, Connect is worth evaluating. It's an evolution of gRPC, not a replacement.


A Comparison Table

Concern REST GraphQL gRPC
Transport HTTP/1.1 or HTTP/2 HTTP/1.1 or HTTP/2 HTTP/2
Encoding JSON JSON Protobuf (binary)
Schema Optional (OpenAPI) Required (SDL) Required (proto)
Code gen Optional (from OpenAPI) Optional (typed clients) Standard
Streaming Server-sent events / WebSocket Subscriptions (WebSocket) Native (4 patterns)
Browser support Excellent Excellent Limited (via Connect)
Public API fit Excellent Good Poor
Internal microservices Good Good Excellent
Caching HTTP caching App-level App-level

The right choice depends on the workload. Most services use REST. Service-to-service in performance-sensitive systems uses gRPC. Data-heavy multi-client APIs sometimes use GraphQL.


What to Take From This Lesson


Try It

Exercise: Warmup (30 minutes). Define a small .proto file (User service with Get and List methods). Implement a Node server and a Node client using @grpc/grpc-js. Verify communication.

Exercise: Core (40 minutes). Add a server-streaming method to the service. Implement a long-running subscription pattern. Test cancellation by client disconnect.

Exercise: Core (30 minutes). Set up buf with a project. Generate TypeScript code. Compare developer experience to the dynamic-loading approach.

Exercise: Stretch (60 minutes). Implement the same service via REST and gRPC. Benchmark both with realistic workload. Compare throughput, latency, payload sizes.


What's Next

Lesson 17.5 covers API versioning - the discipline that lets you evolve an API without breaking existing clients.

Back to Node.js Deep Dive