Moleculer Service Broker Load Balancing Without Client Changes
Scaling a Moleculer service works without client-side changes. The Service Broker registers its actions automatically, making them discoverable and load balancing request-response calls across instances using Round Robin. Transport-agnostic communication works over TCP, Redis, or NATS.
26 Aug 2025, 08:38 UTC

The problem is uneven load when you scale a service
You add a second process for the same Moleculer service and expect calls to spread. If you are using the Service Broker correctly, they will. The broker maintains a registry of live nodes and instances, and it routes request-response calls with built-in load balancing. You do not add a client-side balancer or change business logic.
The useful takeaway: define the service once, run multiple processes with the same service name, and use broker.call. The broker picks an instance using its default strategy. Transport is agnostic, so the same code works over TCP, Redis, or NATS.
How the broker routes and discovers services
Service discovery in Moleculer is automatic. On startup a node connects via a transporter, registers its services and actions, and subscribes to the broker’s internal channels. Other nodes learn about the new instances from the registry. No DNS or manual endpoint list is required.
Request-response uses broker.call(actionName, params). The broker looks up the action in the registry, selects an instance, and forwards the request over the configured transporter. Emit uses broker.emit for fire-and-forget events.
Load balancing is native. The default strategy is Round Robin per action. The broker tracks healthy instances and rotates selection. You can change the strategy per action via the settings.
Worked configuration for load balanced request-response
Create a service with an actions object. The broker exposes the action as v1.math.add.
// math.service.js
module.exports = {
name: 'math',
actions: {
add(ctx) {
return ctx.params.a + ctx.params.b;
}
}
};
Start a broker with a transporter. Run this in two separate Node processes, e.g., process A and process B, on the same network. Node names must differ.
// broker.js
const { ServiceBroker } = require('moleculer');
const MathService = require('./math.service');
const broker = new ServiceBroker({
nodeID: process.env.NODE_ID || 'node-1',
transporter: 'TCP',
logger: console,
logLevel: 'info'
});
broker.createService(MathService);
broker.start().then(() => {
// Verify registration
console.log('Node started', broker.nodeID);
});
Run with:
node broker.js
NODE_ID=node-2 node broker.js
From a third process, call the action:
const { ServiceBroker } = require('moleculer');
const broker = new ServiceBroker({ transporter: 'TCP' });
await broker.start();
const res = await broker.call('math.add', { a: 1, b: 2 });
// Expected check: res === 3
// Practical verification: enable broker metrics or log the nodeID in the action
// to see which instance handled the call over successive requests.
The broker will select instances in round robin order. No client code changes are needed to move from one instance to many. The transporter choice does not affect the call syntax.
Limits and common mistakes
Transporter single point of failure. Using a single Redis or NATS instance without clustering creates a SPOF. The broker registry is replicated via the transporter, so transporter availability is critical. Cluster the transporter or use a highly available deployment.
Timeouts cascade. The broker default request timeout is 10 seconds. Deeply nested calls chain timeouts. If an inner service is slow, the outer call fails and the caller may retry, amplifying load. Set explicit timeouts per action and use circuit breaking for external dependencies.
Distributed traces are not built in. Nested broker.call chains produce distributed stack traces that are hard to follow without correlation IDs. Add a request ID in the context and propagate it, or integrate an OpenTelemetry tracer.
Over-reliance on broker caching. Moleculer supports caching at the broker level to reduce redundant inter-service calls. Cache keys are derived from action name and params. Cache invalidation is manual. Misconfigured TTL can serve stale data.
Common mistake: starting multiple instances with the same nodeID. The broker will treat them as the same node and evict one. Use unique nodeID per process.
Common mistake: assuming emit is reliable. Emit is fire-and-forget with no backpressure. Use request-response when you need acknowledgment.
Practical way to check the result
- Start two nodes with the same service and different nodeIDs over the same transporter.
- In the action, log broker.nodeID and a counter.
- Issue a series of broker.call requests from a client node.
- Observe the logs. With default settings you should see alternating nodeIDs. If all calls hit one node, check transporter connectivity and registry sync.
Rollback is not required for read-only verification. If you change transporter configuration, restart nodes with the new config and confirm registry entries via broker.registry.getService('math').
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.