Skip to content
Express.js ex microservices 5 min read

gRPC with Node & Express

REST over JSON is the right default for public, client-facing APIs, but it is not always the best choice for the chatty, high-volume traffic that flows between your own services. gRPC is a high-performance RPC framework that uses Protocol Buffers for a compact binary wire format and HTTP/2 for multiplexed, streaming transport. This page shows how to define a service contract in protobuf, stand up a Node gRPC server, call it from a client, and bridge gRPC into an Express app — plus when gRPC genuinely beats REST for internal traffic.

Why gRPC for internal traffic

gRPC trades human readability for speed and a strict contract. Because the .proto file is the single source of truth, both sides are generated from the same schema, so a field rename can never silently break a consumer. Payloads are binary rather than verbose JSON, and HTTP/2 lets many calls share one connection.

AspectREST + JSONgRPC + Protobuf
PayloadText JSON, largerBinary, compact
ContractOpenAPI (optional).proto (mandatory)
TransportHTTP/1.1HTTP/2, multiplexed
StreamingAwkward (SSE/WS)First-class, bidirectional
Browser supportNativeNeeds gRPC-Web proxy
Best forPublic APIsInternal service-to-service

Tip: Use gRPC for east-west (service-to-service) traffic and keep REST or GraphQL at the edge for north-south (client-facing) traffic. A browser cannot speak raw gRPC, so a public gRPC API still needs a gateway.

Defining the service contract

Everything starts with a .proto file. You declare messages (typed structures) and a service (a set of RPC methods). Field numbers are part of the wire format and must never be reused.

// proto/users.proto
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 limit = 1;
}

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

GetUser is a classic unary call (one request, one response). ListUsers returns a stream, meaning the server can push many User messages over a single call.

Building the Node gRPC server

The simplest path in Node is @grpc/grpc-js with @grpc/proto-loader, which loads the .proto at runtime — no codegen step required.

npm install @grpc/grpc-js @grpc/proto-loader
// server.js
const path = require('path');
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

const def = protoLoader.loadSync(path.join(__dirname, 'proto/users.proto'), {
  keepCase: true,
  longs: String,
  defaults: true,
});
const { users } = grpc.loadPackageDefinition(def);

const db = {
  u_42: { id: 'u_42', name: 'Ada Lovelace', email: '[email protected]' },
};

function getUser(call, callback) {
  const user = db[call.request.id];
  if (!user) {
    return callback({ code: grpc.status.NOT_FOUND, message: 'user not found' });
  }
  callback(null, user);
}

function listUsers(call) {
  // server-streaming: write many messages, then end
  Object.values(db).slice(0, call.request.limit || 100).forEach((u) => call.write(u));
  call.end();
}

const server = new grpc.Server();
server.addService(users.UserService.service, { getUser, listUsers });
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
  console.log('gRPC server listening on :50051');
});

Output:

gRPC server listening on :50051

Note the error convention: unary handlers signal failure with a structured status code (grpc.status.NOT_FOUND) instead of throwing, and the client receives that code rather than a stack trace.

Calling the service from a client

A consuming service loads the same .proto, creates a stub, and calls methods as if they were local functions. The callback-style API is easily wrapped in a promise for async/await.

// client.js
const path = require('path');
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const { promisify } = require('util');

const def = protoLoader.loadSync(path.join(__dirname, 'proto/users.proto'), { keepCase: true });
const { users } = grpc.loadPackageDefinition(def);

const client = new users.UserService('localhost:50051', grpc.credentials.createInsecure());
const getUser = promisify(client.getUser).bind(client);

async function main() {
  const user = await getUser({ id: 'u_42' });
  console.log(user);
}

main().catch((err) => console.error(err.code, err.details));

Output:

{ id: 'u_42', name: 'Ada Lovelace', email: '[email protected]' }

Bridging gRPC behind Express

A common pattern is an Express edge that speaks REST to browsers but calls internal services over gRPC. The gRPC client becomes a dependency of your route handlers, translating HTTP into RPC and gRPC status codes back into HTTP status codes.

const express = require('express');
const app = express();

// `getUser` is the promisified gRPC stub from above
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await getUser({ id: req.params.id });
    res.json(user);
  } catch (err) {
    if (err.code === grpc.status.NOT_FOUND) {
      return res.status(404).json({ error: 'user not found' });
    }
    next(err); // unexpected -> error middleware
  }
});

app.listen(3000, () => console.log('REST edge on :3000'));

A request to the Express edge now transparently fans out to gRPC:

Output:

GET /users/u_42  ->  200 OK
{ "id": "u_42", "name": "Ada Lovelace", "email": "[email protected]" }

GET /users/nope  ->  404 Not Found
{ "error": "user not found" }

Warning: createInsecure() is for local development only. In production use grpc.ServerCredentials.createSsl(...) and grpc.credentials.createSsl(...) so traffic between services is encrypted and authenticated.

Best Practices

  • Treat the .proto file as a versioned contract: add new fields with new field numbers, and never reuse or renumber existing ones.
  • Use gRPC for internal service-to-service calls and keep a REST/GraphQL edge for browsers, which cannot speak raw gRPC.
  • Return structured grpc.status codes from handlers instead of throwing strings, and map them deliberately to HTTP status codes at the edge.
  • Always pass deadlines from the client ({ deadline } in call options) so a slow service cannot hang the caller indefinitely.
  • Reuse a single long-lived client/channel per target service — HTTP/2 multiplexes many calls over one connection, so creating clients per request wastes resources.
  • Enable TLS credentials in production and put services on a private network; never expose an insecure gRPC port publicly.
Last updated June 14, 2026
Was this helpful?