Scaling Moleculer Services with NATS Transport and Client-Side Load Balancing
Learn how to scale Moleculer microservices horizontally using NATS transport for automatic service discovery and client-side load balancing, with verification steps and common pitfalls.
08 Aug 2026, 14:54 UTC

Scaling a Moleculer application beyond a single Node.js process requires replacing the default in-memory transport with a distributed one. The practical answer is to configure the NATS transport: it lets service instances register themselves, discover peers dynamically, and balance requests on the client side, eliminating the need for hardcoded IP addresses or an external load balancer.
How Distributed Discovery Works
In a multi-node Moleculer cluster, every broker is both a client and a server. When a service starts with a NATS transport, it publishes its name, node ID, and available actions to the shared NATS cluster. Other brokers subscribe to these registry events, so when one service calls another, it already holds a local list of healthy instances. The caller never needs to know which host or port serves the target service.
Request distribution happens on the client side. Before sending a call, the calling broker selects a target instance using a configured strategy—Round Robin, Random, or Least Connections—based on the metadata it received from NATS. This removes the single point of failure and network hop that a traditional proxy introduces.
Worked Configuration
The following example runs three brokers on the same machine, all connected to a local NATS server. The orders service calls the payments service, which is scaled across two instances. Confirm your Moleculer distribution exposes the nats transport option before deploying.
const { ServiceBroker } = require("moleculer");
// Shared NATS connection settings
const natsConfig = {
urls: ["nats://127.0.0.1:4222"]
};
// Orders broker (client side)
const ordersBroker = new ServiceBroker({
nodeID: "orders-node",
transport: "nats",
nats: natsConfig,
// Client-side balancing strategy
registry: {
strategy: "RoundRobin"
}
});
ordersBroker.add({
name: "orders",
actions: {
async checkout(ctx) {
const result = await ctx.call("payments.process", {
amount: ctx.params.amount
});
return { orderStatus: "created", payment: result };
}
}
});
// Payments instance 1
const paymentsA = new ServiceBroker({
nodeID: "payments-a",
transport: "nats",
nats: natsConfig
});
paymentsA.add({
name: "payments",
actions: {
process(ctx) {
return {
status: "paid",
handledBy: this.broker.nodeID
};
}
}
});
// Payments instance 2
const paymentsB = new ServiceBroker({
nodeID: "payments-b",
transport: "nats",
nats: natsConfig
});
paymentsB.add({
name: "payments",
actions: {
process(ctx) {
return {
status: "paid",
handledBy: this.broker.nodeID
};
}
}
});
ordersBroker.start()
.then(() => paymentsA.start())
.then(() => paymentsB.start());
Run a NATS server on 127.0.0.1:4222 before starting the brokers. Each broker connects independently; there is no master election or shared database.
Verifying the Behavior
To confirm discovery and balancing, start the three brokers and call orders.checkout repeatedly. The handledBy field in the response should alternate between payments-a and payments-b under the Round Robin strategy. If you stop payments-a, subsequent calls should fail over to payments-b without configuration changes.
You can also listen for registry events to observe instances joining and leaving:
ordersBroker.on("service.registered", (service) => {
console.log(`Available: ${service.name} on ${service.nodeID}`);
});
ordersBroker.on("service.unregistered", (service) => {
console.log(`Removed: ${service.name} on ${service.nodeID}`);
});
If you stop payments-a, you should see the unregistered event within seconds, confirming that the local registry no longer routes traffic to that node.
Limits and Common Mistakes
- Memory transport is not distributed. The default
transport: "memory"only works inside one Node.js process. It cannot discover services across containers, hosts, or separate processes. Leaving it in production silently breaks scaling. - Circular calls need guards. If
orderscallspaymentsandpaymentscallsorders, a retry loop can hang the call chain. Set explicittimeoutvalues in action definitions or call options so the broker aborts the chain instead of looping indefinitely. - Broadcasts are expensive.
broker.broadcastandbroker.broadcastLocalfan out to every instance. High-frequency broadcasts over NATS can saturate the message bus. Reserve them for cache invalidation or lifecycle events, not per-request work. - NATS availability matters. If the NATS server is unreachable, brokers cannot update their registries. A single NATS node is a dependency; for production, consider a NATS cluster with multiple URLs in the
urlsarray.
A Practical Health Check
After deployment, verify the cluster by watching registry events on any broker. The service.registered event fires when a new instance joins, and service.unregistered fires when it leaves. If the event stream stops while instances are running, check NATS connectivity and broker transport logs before investigating application code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.