DEV Community

Cover image for Moleculer vs gRPC vs tRPC: an RPC protocol is not a service layer
Icebob
Icebob

Posted on

Moleculer vs gRPC vs tRPC: an RPC protocol is not a service layer

Ask an AI assistant "how should my Node.js services talk to each other" and you will get gRPC or tRPC. Both are good answers to a question — just not the same question you might be asking. gRPC and tRPC are ways to call a function on another machine. Moleculer is a service layer: it also calls functions on other machines, but it is mostly about the things around the call — finding the machine, choosing between three copies of it, giving up on a slow one, publishing "this happened" to whoever cares. Comparing them head-to-head is like comparing HTTP with Express. This post does it anyway, because the confusion is real, and because the honest answer is that they overlap in one place and differ everywhere else.

The plan: build the same tiny products service three times, then throw the same three problems at each — a second instance, a dead instance, a slow instance — and watch what each tool does out of the box. Then the parts where Moleculer is genuinely weaker (types, other languages, browsers), the throughput shape on one box, and two setups where you use them together, which is where most real systems end up.

Every snippet was executed and the output pasted. The code is in the example repo; run.sh reproduces everything below in about two minutes.

Versions: @grpc/grpc-js 1.14.4 + @grpc/proto-loader 0.8.1, @trpc/server + @trpc/client 11.18 with zod 4.5, Moleculer 0.15.2, Node 22. Moleculer terms, glossed once: a service is an object with actions (callable functions) and events (pub/sub handlers); a broker hosts services on a node (a process) and talks to other nodes over a transporter (NATS here, plain TCP later); the registry is the broker's live list of which node has which action — the thing gRPC calls a resolver and Kubernetes calls service discovery.

What each one is

gRPC is a protocol: HTTP/2 framing, Protocol Buffers for the payload, a .proto file as the contract, and generated (or, in Node, runtime-loaded) stubs in a dozen languages. It has some client-side features — deadlines, retries and load-balancing policies configured through a "service config" — but they are opt-in and they operate on a list of addresses someone else has to provide. Discovery, health, circuit breaking, pub/sub: not gRPC's job; that is what service meshes and xDS are for.

tRPC is a TypeScript library: you write a router of procedures with zod inputs, export its type, and a client in another TypeScript project gets full autocompletion and return types with no codegen and no schema file. On the wire it is plain HTTP + JSON (batched). It was designed for a full-stack TypeScript app — a Next.js frontend calling its own backend — and it is superb at that. It has no notion of multiple servers at all: a client has one URL.

Moleculer is a framework: services, actions, events, a registry, load balancing, timeouts, retries, circuit breaker, bulkhead, caching, tracing, metrics, and pluggable transport. The "RPC" part of it — broker.call("products.get", { id: 2 }) — is a thin JSON-over-transporter message that the previous seven posts of this series have used. It is Node.js only, and its TypeScript story is weaker than tRPC's (post 3 is the honest version).

Layer diagram: application code on top; a service layer (registry, load balancer, timeout/retry/breaker, events, tracing); an RPC layer (contract, types, serialization); and transport at the bottom. gRPC covers the RPC layer plus a slice of the service layer; tRPC covers the RPC layer with a full types column; Moleculer covers the service and RPC layers with a thinner types column.

That is the whole post in one picture. The rest is proof.

The same service, three times

Three products, get by id, list with a limit, and every response says which instance served it (servedBy), because that is what we will be watching.

gRPC — the contract lives in a .proto file:

syntax = "proto3";
package products;

service Products {
  rpc Get (GetRequest) returns (Product);
  rpc List (ListRequest) returns (ProductList);
}

message GetRequest  { int32 id = 1; }
message ListRequest { int32 limit = 1; }
message Product     { int32 id = 1; string name = 2; double price = 3; string served_by = 4; }
message ProductList { repeated Product items = 1; }
Enter fullscreen mode Exit fullscreen mode
// grpc/server.js
const pkg = grpc.loadPackageDefinition(
  protoLoader.loadSync(path.join(__dirname, "products.proto"), { keepCase: true, longs: Number, defaults: true })
).products;

const server = new grpc.Server();
server.addService(pkg.Products.service, {
  Get(call, callback) {
    const product = PRODUCTS.find((p) => p.id === call.request.id);
    if (!product) return callback({ code: grpc.status.NOT_FOUND, message: `product ${call.request.id} not found` });
    callback(null, { ...product, served_by: NAME });
  },
  List(call, callback) {
    callback(null, { items: PRODUCTS.slice(0, call.request.limit || 10).map((p) => ({ ...p, served_by: NAME })) });
  },
});
server.bindAsync(`0.0.0.0:${PORT}`, grpc.ServerCredentials.createInsecure(), () => { /* … */ });
Enter fullscreen mode Exit fullscreen mode

tRPC — the contract is the code:

// trpc/server.ts
const t = initTRPC.create();

export const appRouter = t.router({
  products: t.router({
    get: t.procedure
      .input(z.object({ id: z.number().int() }))
      .query(({ input }) => {
        const product = PRODUCTS.find((p) => p.id === input.id);
        if (!product) throw new TRPCError({ code: "NOT_FOUND", message: `product ${input.id} not found` });
        return { ...product, servedBy: NAME };
      }),
    list: t.procedure
      .input(z.object({ limit: z.number().int().default(10) }))
      .query(({ input }) => PRODUCTS.slice(0, input.limit).map((p) => ({ ...p, servedBy: NAME }))),
  }),
});

// The client imports this TYPE only — no code, no schema file, no codegen.
export type AppRouter = typeof appRouter;

createHTTPServer({ router: appRouter }).listen(PORT);
Enter fullscreen mode Exit fullscreen mode

Moleculer — a service object; the parameter schema is a fastest-validator shorthand:

// moleculer/products.service.js
module.exports = {
  name: "products",
  actions: {
    get: {
      params: { id: "number|integer|convert" },
      async handler(ctx) {
        const product = PRODUCTS.find((p) => p.id === ctx.params.id);
        if (!product) throw new Errors.MoleculerClientError(`product ${ctx.params.id} not found`, 404, "NOT_FOUND", { id: ctx.params.id });
        return { ...product, servedBy: NAME };
      },
    },
    list: {
      params: { limit: { type: "number", integer: true, default: 10 } },
      handler(ctx) {
        return PRODUCTS.slice(0, ctx.params.limit).map((p) => ({ ...p, servedBy: NAME }));
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode
// moleculer/node.js — a process that hosts the service
const broker = new ServiceBroker(require("./moleculer.config.js")); // nodeID, transporter: "nats://localhost:4222", …
broker.createService(require("./products.service.js"));
broker.start();
Enter fullscreen mode Exit fullscreen mode

And one call to each, hit and miss:

--- grpc: get 2, then get 99
  → {"id":2,"name":"Mouse","price":19.9,"served_by":"products-1"}
  → error code 5 NOT_FOUND: product 99 not found
--- trpc: get 2, then get 99
  → {"id":2,"name":"Mouse","price":19.9,"servedBy":"products-1"}
  → error NOT_FOUND (HTTP 404): product 99 not found
--- mol: get 2, then get 99
  → {"id":2,"name":"Mouse","price":19.9,"servedBy":"products-1"}
  → error MoleculerClientError (404 NOT_FOUND): product 99 not found
Enter fullscreen mode Exit fullscreen mode

Same behaviour, three idioms. The client side is where they start to look different:

// gRPC: a stub from the proto, a target address, a callback API
const client = new pkg.Products("127.0.0.1:50051", grpc.credentials.createInsecure());
client.Get({ id: 2 }, (err, res) => …);
Enter fullscreen mode Exit fullscreen mode
// tRPC: a typed proxy from the router type and one URL
const client = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "http://127.0.0.1:4001" })] });
const p = await client.products.get.query({ id: 2 }); // p is { id: number; name: string; price: number; servedBy: string }
Enter fullscreen mode Exit fullscreen mode
// Moleculer: a broker that joins the cluster; no address of anything
const broker = new ServiceBroker({ transporter: "nats://localhost:4222" });
await broker.start();
const p = await broker.call("products.get", { id: 2 }); // p is `any` — see "Type safety" below
Enter fullscreen mode Exit fullscreen mode

At this point tRPC is the nicest and Moleculer is the heaviest (it needs NATS running). Now the interesting part.

Problem 1: a second instance

Start products-2 next to products-1 for all three and make four calls.

--- gRPC: ipv4:127.0.0.1:50051,127.0.0.1:50052 + round_robin service config
  call 1: served by products-1
  call 2: served by products-1
  call 3: served by products-2
  call 4: served by products-1
--- tRPC: one URL — the second server on :4002 is simply never called
  call 1: served by products-1
  call 2: served by products-1
  call 3: served by products-1
  call 4: served by products-1
--- Moleculer: nothing configured, the registry knows both nodes
  products.get endpoints: products-1, products-2
  call 1: served by products-2
  call 2: served by products-1
  call 3: served by products-2
  call 4: served by products-1
Enter fullscreen mode Exit fullscreen mode

gRPC can balance, and it took this to make it do so:

// grpc/client.js — a resolver that yields several addresses, plus a service config
const serviceConfig = { loadBalancingConfig: [{ round_robin: {} }], methodConfig: [/* retry policy, later */] };
const client = new pkg.Products("ipv4:127.0.0.1:50051,127.0.0.1:50052", grpc.credentials.createInsecure(), {
  "grpc.service_config": JSON.stringify(serviceConfig),
});
Enter fullscreen mode Exit fullscreen mode

The default policy is pick_first, and the addresses come from the target string — in production from DNS or xDS. Either way the client is told the list; nobody tells it when the list changes. (The first two calls both landing on products-1 is grpc-js starting to route before the second connection was ready — round robin over connected subchannels.)

tRPC has one URL. If you want two servers, you put a reverse proxy in front and it is the proxy that balances; tRPC does not know or care. That is not a flaw — it is the shape of the tool — but it means "tRPC between microservices" quietly implies "plus nginx/Envoy/an ingress and a plan for how they find the instances".

Moleculer's client — which is just another node — learned about both instances from the registry the moment it started, and round-robins between them (the discovery post covers the strategies). No address was written anywhere.

Problem 2: an instance dies

Stop products-2 and call again.

--- gRPC: the closed connection drops out of round_robin; the static list still names it
  call 1: served by products-1
  call 2: served by products-1
  call 3: served by products-1
  call 4: served by products-1
--- Moleculer: the node left the registry
  products.get endpoints: products-1
  call 1: served by products-1
  call 2: served by products-1
  call 3: served by products-1
  call 4: served by products-1
Enter fullscreen mode Exit fullscreen mode

Both cope. gRPC noticed the TCP connection close and stopped picking that subchannel; it will keep trying to reconnect to :50052 in the background because the address is still on its list. Moleculer got a disconnect message from the node (a graceful stop) — for a crash it would take the heartbeat timeout, 6 s in this config — and removed it from the registry. tRPC is not in this section because one URL means there is nothing to fail over to.

Problem 3: an instance is slow

products-2 comes back, but every answer takes 800 ms. This is the case that separates "can call a remote function" from "can run a system", because a slow dependency is worse than a dead one: it ties up the caller.

gRPC has deadlines, per call:

client.Get({ id: 1 }, { deadline: Date.now() + 300 }, callback);
Enter fullscreen mode Exit fullscreen mode
--- gRPC: deadline 300 ms against the slow server
  → DEADLINE_EXCEEDED after 306 ms — Deadline exceeded after 0.297s,name resolution: 0.028s,metadata filters: 0.003s,time to current attempt start: 0.001s,LB pick: 0.019s,remote_addr=127.0.0.1:50052
Enter fullscreen mode Exit fullscreen mode

The deadline is propagated to the server too (the handler can check call.getDeadline()), which is a genuinely good design. But it is the only thing that happens. The round-robin client keeps sending every other call to the slow instance, and the retry policy — which we configured with retryableStatusCodes: ["UNAVAILABLE"], the only sensible default — does not fire on DEADLINE_EXCEEDED, because retrying a call that took too long, with the same deadline, is usually wrong:

--- gRPC: round robin keeps sending every second call to the slow instance
  call 1: served by products-2
  call 2: served by products-2
  call 3: served by products-1
  call 4: served by products-2
Enter fullscreen mode Exit fullscreen mode

No latency awareness, no outlier ejection, no circuit breaker. Those exist in the gRPC ecosystem — in Envoy, in xDS-driven load balancing, in a mesh — but not in the client library.

tRPC has neither deadlines nor retries at the protocol level; you pass an AbortSignal (signal: AbortSignal.timeout(300)) and the server keeps working on the request. Retrying is the client application's problem, and with one URL a retry goes to the same server. Fine for a browser talking to a backend behind a load balancer; not a service-to-service story.

Moleculer treats the slow instance as a system problem, and the config is three blocks that the resilience post explains in detail:

// moleculer/moleculer.config.js
requestTimeout: 300, // a broker-wide default; per-call override: broker.call(…, { timeout })
retryPolicy: {
  enabled: true, // the policy must be enabled here for a per-call `retries` to have any effect
  retries: 0,    // …but by default we don't retry; the demo passes { retries: 1 } per call
  delay: 100, maxDelay: 1000, factor: 2,
  check: (err) => err && !!err.retryable, // timeouts and "service unavailable" are; a 404 is not
},
circuitBreaker: {
  enabled: true,
  threshold: 0.5,     // open when ≥ 50 % of the calls in the window failed…
  minRequestCount: 3, // …after at least 3 calls (default 20)
  windowTime: 60,
  halfOpenTime: 5000, // ms until a probe call is let through again
  check: (err) => err && err.code >= 500,
},
Enter fullscreen mode Exit fullscreen mode
--- plain calls, 300 ms requestTimeout, one instance is slow:
  call 1: RequestTimeoutError after 307 ms — Request is timed out when call 'products.get' action on 'products-2' node. (node products-2)
  call 2: served by products-1 in 8 ms
  call 3: RequestTimeoutError after 302 ms — Request is timed out when call 'products.get' action on 'products-2' node. (node products-2)
  call 4: served by products-1 in 6 ms
--- same, with retries: 1 → the retry goes to another instance, and the breaker counts the timeouts:
  call 1: served by products-1 in 409 ms
  call 2: served by products-1 in 406 ms
  🔴 circuit OPENED for products.get on products-2 (3/3 failed)
  call 3: served by products-1 in 410 ms
  call 4: served by products-1 in 5 ms
--- the circuit is open for the slow endpoint; plain calls again:
  call 1: served by products-1 in 6 ms
  call 2: served by products-1 in 3 ms
  call 3: served by products-1 in 4 ms
  call 4: served by products-1 in 5 ms
  endpoint state: products-2=false, products-1=true
Enter fullscreen mode Exit fullscreen mode

Three stages. Plain calls: the timeout works like a gRPC deadline (and, like gRPC's, it is propagated — the callee stops too). With retries: 1: the retry is routed by the registry to the other instance, so the caller sees a slow success instead of an error, and each timeout is counted against products-2. After three, the breaker opens that one endpoint — the node stays in the registry, it just receives no calls until a probe succeeds 5 s later — and call 4 onwards is back to 5 ms. The 🔴 line is the $circuit-breaker.opened event, which any service in the cluster can subscribe to; that is how you get an alert instead of a graph that looks odd.

One honest footnote, because it cost me an hour: a plain timeout with no retry does not trip the breaker. The breaker counts failures that come back from the callee; a caller-side timeout is raised outside it, so the callee's own "timed out" reply arrives to a request that has already been abandoned. With the retry policy enabled, the retry keeps the request alive long enough for that reply to be counted. The practical rule: if you enable circuitBreaker, enable retryPolicy too (even with retries: 0 as the default, the mechanism is then in place for the calls that ask for it). The resilience post's RETRY=1 CB=1 demo worked for exactly this reason, which I did not know when I wrote it.

Problem 4: a third instance appears

A deploy scaled the service up. products-3 starts on a port the clients have never heard of.

--- gRPC: the client's address list is static — :50053 is never used
  call 1: served by products-1
  call 2: served by products-1
  call 3: served by products-1
  call 4: served by products-1
--- Moleculer: a new node in the registry, no client change
  products.get endpoints: products-1, products-3
  call 1: served by products-3
  call 2: served by products-1
  call 3: served by products-3
  call 4: served by products-1
Enter fullscreen mode Exit fullscreen mode

This is the difference in one screen. gRPC did nothing wrong; it was never told. The thing that would tell it — a DNS record that updates, an xDS control plane, Kubernetes — is the infrastructure Moleculer ships in the box. Whether you want that in the box or in the platform is a legitimate architectural choice, and the Kubernetes post is about exactly that; but it is a choice you have to make, and "just use gRPC" hides it.

Type safety, honestly

This is where the comparison flips.

tRPC is the best-in-class answer. The client's p.servedBy above is typed because the client was created from typeof appRouter. Rename a field on the server and every caller fails to compile. Zero build step, zero schema, one language. If both ends are TypeScript and both ends are yours, nothing else comes close.

gRPC gets there with codegen — protoc with a TypeScript plugin, or proto-loader-gen-types — and the .proto file is a contract that other languages honour too. It is more ceremony than tRPC and less pleasant (callbacks, keepCase decisions, int64 as strings), but it is the only one of the three whose types survive a language boundary.

Moleculer has the weakest story of the three, and there is no point pretending otherwise. broker.call returns Promise<any> unless you pass generics yourself — broker.call<Product, { id: number }>("products.get", { id }) — and nothing checks that "products.get" exists or that the generics match what the handler returns. Parameter validation is real but runtime (fastest-validator, or zod via a custom validator), so a wrong call fails at request time with a 422, not at compile time. The community moleculer-decorators package and the patterns in post 3 make services pleasant to write in TypeScript; they do not make calls between services type-checked. If end-to-end static typing across services is the thing you value most, tRPC wins, and the last section shows how to have both.

Other languages, browsers, streaming

gRPC is the polyglot option, full stop. A Go service and a Node service share the .proto and nothing else. It also has real streaming — server, client and bidirectional — over HTTP/2, which neither of the others matches (tRPC has subscriptions over SSE/WebSocket; Moleculer has streams for request/response and events for fan-out, not a bidirectional channel). What gRPC cannot do is talk to a browser without gRPC-Web and a proxy.

tRPC is the browser option. That is what it is for; httpBatchLink even collapses ten calls made in the same tick into one request. It does not exist outside TypeScript (there are OpenAPI bridges, with the type safety left at the door).

Moleculer is Node.js only — every node in the cluster is a Node process running the broker. Browsers reach it through moleculer-web, an API gateway service that maps HTTP routes to actions (the NestJS post shows one). Other languages reach it by speaking the transporter protocol — there are Go and Java ports of varying completeness — or, more sensibly, through a gRPC or HTTP boundary owned by one Node service, which is the second setup below.

Throughput shape

Same box, loopback, one server instance, one client process, 2000 calls: first one at a time, then all in flight. These are not benchmarks — the sequential numbers are dominated by the client library's per-call overhead on a small VM, and a real system is never one client on loopback. Read them as shapes.

  gRPC unary, sequential: 2000 calls in 8677 ms (230 req/s, 4.34 ms/call)
  gRPC unary, 2000 in flight: 1305 ms (1533 req/s)
  tRPC over HTTP, sequential: 2000 calls in 16500 ms (121 req/s, 8.25 ms/call)
  tRPC over HTTP, 2000 in flight (batched): 383 ms (5222 req/s)
  tRPC over HTTP, sequential: 2000 calls in 11217 ms (178 req/s, 5.61 ms/call)   ← httpLink, no batching
  tRPC over HTTP, 2000 in flight: 1905 ms (1050 req/s)
  Moleculer over nats, sequential: 2000 calls in 6473 ms (309 req/s, 3.24 ms/call)
  Moleculer over nats, 2000 in flight: 198 ms (10101 req/s)
  Moleculer over TCP, sequential: 2000 calls in 2121 ms (943 req/s, 1.06 ms/call)
  Moleculer over TCP, 2000 in flight: 226 ms (8850 req/s)
Enter fullscreen mode Exit fullscreen mode

Three things worth taking from it. tRPC's batching is the reason its "in flight" number is thirteen times its sequential one: 2000 calls became 20 HTTP requests. Moleculer over NATS is the fastest of the three at concurrency because the transporter connection is one long-lived socket per node and the broker is fire-and-forget on send; over the TCP transporter (no NATS at all, nodes talk directly) it is also the fastest sequentially. And the per-call cost is a few milliseconds for all of them on this machine — the wire format is not where your latency budget goes; the slow instance in Problem 3 is.

Using them together

Most systems that ask "gRPC or Moleculer" end up with a service layer and an RPC protocol, because they solve different problems. Two setups, both running.

Composition diagram: a browser calls a tRPC gateway over HTTP with end-to-end types; the gateway is a Moleculer node whose procedures call products.* on the cluster over NATS; an inventory service on the cluster wraps a gRPC client to an external Go service.

tRPC in front of a Moleculer cluster

The gateway is a Moleculer node with no services of its own, exposing a tRPC router whose procedures call the cluster. The browser gets tRPC's types; the calls get the registry, the balancer and the breaker.

// combined/gateway.ts
const broker = new ServiceBroker({ ...(config as BrokerOptions), nodeID: "trpc-gateway" });

type Product = { id: number; name: string; price: number; servedBy: string }; // broker.call is untyped — we declare

const toTRPCError = (err: any) =>
  err?.code === 404 ? new TRPCError({ code: "NOT_FOUND", message: err.message })
  : err?.code === 504 ? new TRPCError({ code: "TIMEOUT", message: err.message })
  : new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: err?.message ?? String(err) });

export const gatewayRouter = t.router({
  products: t.router({
    get: t.procedure
      .input(z.object({ id: z.number().int() }))
      .query(({ input }) => broker.call<Product, { id: number }>("products.get", input).catch((e) => { throw toTRPCError(e); })),
    list: t.procedure
      .input(z.object({ limit: z.number().int().default(10) }))
      .query(({ input }) => broker.call<Product[], { limit: number }>("products.list", input)),
  }),
});
export type GatewayRouter = typeof gatewayRouter;

await broker.start();
await broker.waitForServices("products");
createHTTPServer({ router: gatewayRouter }).listen(4010);
Enter fullscreen mode Exit fullscreen mode

The tRPC client is the one from the first section with the router type swapped:

[trpc-gateway] tRPC gateway on :4010, backed by products-1, products-2
  call 1: Keyboard served by products-1
  call 2: Keyboard served by products-2
  call 3: Keyboard served by products-1
  call 4: Keyboard served by products-2
  id 99: NOT_FOUND (HTTP 404) — product 99 not found
Enter fullscreen mode Exit fullscreen mode

One URL for the browser, two instances behind it, no proxy — the balancing moved from where tRPC cannot do it to where Moleculer does it by default. (moleculer-web does the same job for REST/GraphQL clients; tRPC is the choice when the consumer is your own TypeScript frontend.)

A gRPC service behind a Moleculer action

The other direction: an inventory service written in Go — stood in for by a Node gRPC server here, nothing on the Moleculer side depends on that — that the rest of the cluster should be able to call like any other action.

// combined/inventory.service.js
module.exports = {
  name: "inventory",
  settings: { grpcAddress: process.env.INVENTORY_GRPC || "127.0.0.1:50060" },

  actions: {
    check: {
      params: { sku: "string" },
      handler(ctx) {
        return new Promise((resolve, reject) => {
          // Turn the Moleculer timeout budget into a gRPC deadline, so the remote side stops too.
          const opts = ctx.options.timeout > 0 ? { deadline: Date.now() + ctx.options.timeout } : {};
          this.client.Check({ sku: ctx.params.sku }, opts, (err, res) =>
            err ? reject(this.mapError(err, ctx)) : resolve({ sku: res.sku, inStock: res.in_stock, warehouse: res.warehouse }));
        });
      },
    },
  },

  methods: {
    // gRPC status → Moleculer error, so callers see the same error shapes as from any other service
    mapError(err, ctx) {
      switch (err.code) {
        case grpc.status.NOT_FOUND:         return new Errors.MoleculerClientError(err.details, 404, "NOT_FOUND", { sku: ctx.params.sku });
        case grpc.status.DEADLINE_EXCEEDED: return new Errors.RequestTimeoutError({ action: "inventory.check (gRPC)", nodeID: this.settings.grpcAddress });
        case grpc.status.UNAVAILABLE:       return new Errors.MoleculerRetryableError(`inventory gRPC backend unavailable: ${err.details}`, 503, "UNAVAILABLE");
        default:                            return new Errors.MoleculerError(err.details || err.message, 500, `GRPC_${grpc.status[err.code]}`);
      }
    },
  },

  created() { this.client = new pkg.Inventory(this.settings.grpcAddress, grpc.credentials.createInsecure()); },
  stopped() { this.client.close(); },
};
Enter fullscreen mode Exit fullscreen mode

Any node in the cluster calls inventory.check; only this file knows gRPC exists. The backend answers in 200 ms in the demo:

  SKU-1: {"sku":"SKU-1","inStock":42,"warehouse":"WH-EU-1"} (268 ms)
  SKU-9: MoleculerClientError (404 NOT_FOUND) — unknown sku SKU-9 (217 ms)
  SKU-1, timeout 150 ms vs a slow backend: RequestTimeoutError (504 REQUEST_TIMEOUT) — Request is timed out when call 'inventory.check' action on 'inventory-1' node. (153 ms)
--- inventory-1 log:
  WARN  INVENTORY: gRPC deadline hit for sku SKU-1 (budget was 150 ms)
Enter fullscreen mode Exit fullscreen mode

The third line is the one to notice: the caller's 150 ms timeout became a 150 ms gRPC deadline, the Go side gave up at the same moment, and the wrapper logged it. The UNAVAILABLE → MoleculerRetryableError mapping means a restart of the Go service is retried by the cluster's retry policy, and the breaker counts real failures — the resilience layer now covers a service that was never written for it.

Which one, when

  • Your frontend is TypeScript and calls your own backend — tRPC, no contest. If that backend is a cluster of services, put tRPC on the gateway node and let Moleculer do the rest, as above.
  • Services in different languages must call each other, or you need bidirectional streaming — gRPC for that boundary. Wrap it in a Moleculer service if the rest of your system is Node, so the boundary gets timeouts, retries and a breaker without every caller reimplementing them.
  • A fleet of Node.js services that need to find each other, balance, fail over and publish events — and you do not want to run a mesh to get it — that is what Moleculer is, and the RPC inside it is the least interesting part. Accept the weaker static typing, or fix it at the edges with tRPC.
  • Already on Kubernetes with a mesh doing discovery, LB and retries — gRPC is a natural fit and Moleculer's registry is redundant; you would use it for events, caching and the programming model, or not at all. (The Kubernetes post argues the other case.)

The unhelpful framing is "which RPC is fastest". Problem 3 above is what a real outage looks like, and the interesting question is how much of the answer to it your tool ships with versus how much you are about to write.


Code for this post: examples/08-vs-grpc-trpc — run.sh reproduces every demo against a local NATS container. @grpc/grpc-js 1.14.4, @trpc/server 11.18, zod 4.5, Moleculer 0.15.2, Node 22.

Previous in the series: Event-driven architecture in Node.js: pub/sub with NATS, Kafka and Redis. This is the last post of the series.

Top comments (0)