NodeJSgRPC

gRPC with Node.js

gRPC (Google Remote Procedure Call) is a high-performance RPC framework that uses Protocol Buffers (protobuf) as its interface definition language and serialization format, and HTTP/2 as the transport. Where REST sends JSON over HTTP/1.1, gRPC sends compact binary frames over a multiplexed HTTP/2 connection — typically 5–10× faster to serialize and 30–50% smaller on the wire. More importantly, you define your API as a typed .proto schema, and the framework generates strongly-typed client and server stubs in any language. This page covers the gRPC model, writing a .proto contract, and building both a server and client in Node using the @grpc/grpc-js and @grpc/proto-loader packages.

gRPC vs REST

Dimension

REST/JSON

gRPC/Protobuf

Transport

HTTP/1.1 (one request per connection)

HTTP/2 (multiplexed streams, one connection)

Serialization

JSON (text, human-readable)

Protocol Buffers (binary, compact, fast)

Schema

Optional (OpenAPI)

Required .proto file — strictly typed

Code generation

Optional

First-class — generates client/server stubs

Streaming

Limited (SSE, WebSocket workarounds)

Built-in: client streaming, server streaming, bidirectional

Browser support

Native

Requires gRPC-Web proxy

Debugging

Easy (curl, browser devtools)

Harder (binary — needs grpcurl or Postman)

Best for

Public APIs, browser clients

Internal service-to-service calls, microservices

Use gRPC for internal microservice communication where performance and schema enforcement matter; use REST for public APIs and browser-facing endpoints
gRPC's advantages are most pronounced in **internal service-to-service communication**: the binary encoding is meaningfully faster than JSON, HTTP/2 multiplexing eliminates the connection-per-request overhead, and the `.proto` schema acts as a machine-verifiable contract between teams. The cost is that browsers can't speak raw gRPC (they need the gRPC-Web shim), and debugging requires tools that understand the binary protocol. The practical pattern for microservices is **REST externally** (for browser and third-party clients) and **gRPC internally** (for service-to-service calls where both ends are controlled). gRPC also enables streaming patterns — server streaming for real-time data push and bidirectional streaming for chat/collaboration — that REST handles awkwardly.
Defining a .proto contract

Text
// proto/user.proto
syntax = "proto3";

package user;

// Service definition: each rpc = one method
service UserService {
  rpc GetUser    (GetUserRequest)    returns (UserResponse);
  rpc CreateUser (CreateUserRequest) returns (UserResponse);
  rpc ListUsers  (ListUsersRequest)  returns (stream UserResponse); // server streaming
}

// Messages: typed, numbered fields (numbers must never change after deployment)
message GetUserRequest {
  string id = 1;
}

message CreateUserRequest {
  string name  = 1;
  string email = 2;
}

message ListUsersRequest {
  int32 page     = 1;
  int32 pageSize = 2;
}

message UserResponse {
  string id    = 1;
  string name  = 2;
  string email = 3;
}
Field numbers in .proto files are permanent — once deployed, never reuse or remove a field number; add new fields with new numbers to maintain backward compatibility
Protocol Buffers use field **numbers** (not names) to identify fields in the binary encoding. If you change field number 1 from `id` to `userId`, old clients will put the user ID in field 1 and new clients will read field 1 as an empty string — silent data corruption, not an error. The rules for safe evolution: **never reuse** a field number for a different type; **never remove** a field number (mark it `reserved` instead); **only add** new fields with new numbers; in proto3 all fields are optional by default (missing = zero value), so adding a new field is backward-compatible. Treat field numbers like primary keys — they're forever.
Installing gRPC packages

Bash
npm install @grpc/grpc-js @grpc/proto-loader
Server implementation

TS
import path from 'node:path'
import * as grpc from '@grpc/grpc-js'
import * as protoLoader from '@grpc/proto-loader'

// Load the .proto at runtime (no code generation step required with proto-loader)
const packageDef = protoLoader.loadSync(path.join(__dirname, '../proto/user.proto'), {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
})
const proto = grpc.loadPackageDefinition(packageDef) as any
const UserService = proto.user.UserService

// In-memory store for demo
const users: Record<string, { id: string; name: string; email: string }> = {}

// Implement each rpc method:
const serviceImpl = {
  GetUser(
    call: grpc.ServerUnaryCall<{ id: string }, unknown>,
    callback: grpc.sendUnaryData<unknown>
  ) {
    const user = users[call.request.id]
    if (!user) {
      return callback({ code: grpc.status.NOT_FOUND, message: 'User not found' })
    }
    callback(null, user)
  },

  CreateUser(
    call: grpc.ServerUnaryCall<{ name: string; email: string }, unknown>,
    callback: grpc.sendUnaryData<unknown>
  ) {
    const id = String(Date.now())
    const user = { id, ...call.request }
    users[id] = user
    callback(null, user)
  },

  ListUsers(call: grpc.ServerWritableStream<{ page: number; pageSize: number }, unknown>) {
    // Server streaming: write multiple responses then end the stream
    const all = Object.values(users)
    for (const user of all) {
      call.write(user)
    }
    call.end()
  },
}

// Start the server:
const server = new grpc.Server()
server.addService(UserService.service, serviceImpl)
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), (err, port) => {
  if (err) throw err
  console.log(`gRPC server listening on port ${port}`)
})
Client implementation

TS
import path from 'node:path'
import * as grpc from '@grpc/grpc-js'
import * as protoLoader from '@grpc/proto-loader'
import { promisify } from 'node:util'

const packageDef = protoLoader.loadSync(path.join(__dirname, '../proto/user.proto'), {
  keepCase: true, longs: String, enums: String, defaults: true, oneofs: true,
})
const proto = grpc.loadPackageDefinition(packageDef) as any

const client = new proto.user.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure()   // use createSsl() in production
)

// Promisify unary calls for async/await usage:
const getUser  = promisify(client.GetUser.bind(client))
const createUser = promisify(client.CreateUser.bind(client))

async function demo() {
  // Unary call:
  const created = await createUser({ name: 'Alice', email: 'alice@example.com' })
  console.log('Created:', created)

  const fetched = await getUser({ id: created.id })
  console.log('Fetched:', fetched)

  // Server streaming:
  const stream = client.ListUsers({ page: 1, pageSize: 10 })
  stream.on('data', (user: unknown) => console.log('User:', user))
  stream.on('end', () => console.log('Stream ended'))
  stream.on('error', (err: Error) => console.error('Stream error:', err))
}
Promisify unary gRPC calls for async/await — streaming calls use the EventEmitter interface and cannot be promisified the same way
`@grpc/grpc-js` callbacks follow the Node `(error, result)` convention, so `util.promisify` works cleanly for unary calls. Streaming RPCs — server streaming, client streaming, and bidirectional — return stream objects that emit `data`, `end`, and `error` events. You can wrap them in a custom async iterator or use `pipeline` from `node:stream` for backpressure, but you can't `promisify` them directly since they produce multiple values over time. For production code, consider using the `@grpc/grpc-js` async iteration support or a wrapper library that exposes streaming gRPC as async generators.
Error handling and status codes

TS
// Server: return status codes via the callback error argument:
callback({ code: grpc.status.NOT_FOUND,          message: 'User not found' })
callback({ code: grpc.status.INVALID_ARGUMENT,   message: 'Email is required' })
callback({ code: grpc.status.INTERNAL,           message: 'Database error' })
callback({ code: grpc.status.ALREADY_EXISTS,     message: 'Email already registered' })
callback({ code: grpc.status.UNAUTHENTICATED,    message: 'Missing credentials' })
callback({ code: grpc.status.PERMISSION_DENIED,  message: 'Insufficient permissions' })

// Client: check the status code to distinguish error types:
try {
  const user = await getUser({ id: '999' })
} catch (err: any) {
  if (err.code === grpc.status.NOT_FOUND)     console.log('No such user')
  if (err.code === grpc.status.UNAVAILABLE)   console.log('Server down — retry later')
}
TLS in production

TS
import fs from 'node:fs'

// Server with TLS:
const serverCreds = grpc.ServerCredentials.createSsl(
  fs.readFileSync('certs/ca.crt'),
  [{ cert_chain: fs.readFileSync('certs/server.crt'), private_key: fs.readFileSync('certs/server.key') }],
  false   // true = require client certificates (mutual TLS)
)
server.bindAsync('0.0.0.0:50051', serverCreds, ...)

// Client with TLS:
const clientCreds = grpc.credentials.createSsl(fs.readFileSync('certs/ca.crt'))
const client = new proto.user.UserService('api.internal:50051', clientCreds)
Never use createInsecure() outside localhost development — gRPC over plaintext on the internet exposes all service-to-service traffic to interception
`grpc.ServerCredentials.createInsecure()` and `grpc.credentials.createInsecure()` disable TLS entirely. This is fine when both server and client run on the same host (or a private network behind a mutual-TLS service mesh like Istio or Linkerd), but any traffic crossing a public network or an untrusted host must use TLS. In Kubernetes, the common pattern is to deploy a service mesh that handles mTLS transparently — services talk to each other over plain gRPC within the mesh, and the sidecar proxies handle certificate management and encryption. If you're not using a service mesh, configure TLS explicitly with the patterns above.
Next
Patterns for choosing between REST, gRPC, and queues per interaction: [Service Communication Patterns](/nodejs/service-communication).