Scaling and limits
This page collects the knobs and constraints you will hit once your scripted server moves out of toy territory and starts serving real clients with real address spaces.
What bootstrapServer exposes
The boot config covers the everyday knobs: port, endpoint, applicationName, productUri, nodesets, securityPolicies, securityModes, allowAnonymous, users, discoveryServerEndpointUrl, registerServerMethod, shutdownTimeoutMs, forceRebuild, and the onPopulate callback.
Anything else that affects scale — connection / session limits, operation limits, frugal address-space mode — you tune from inside onPopulate, where you have full access to the live addressSpace.
Frugal address space
If you have a lot of variables (thousands), enable addressSpace.isFrugal = true inside onPopulate while you build them. This skips reverse-reference materialisation that is rarely needed for raw I/O variables and dramatically reduces memory overhead.
onPopulate: (addressSpace, exposed) => {
const namespace = addressSpace.getOwnNamespace();
const dataset = namespace.addObject({ organizedBy: "ObjectsFolder", browseName: "Dataset" });
addressSpace.isFrugal = true;
for (let i = 0; i < 5000; i++) {
namespace.addVariable({
browseName: `Var${i.toString().padStart(4, "0")}`,
nodeId: `s=Var${i.toString().padStart(4, "0")}`,
componentOf: dataset,
dataType: "Double",
value: { dataType: "Double", value: 0 },
});
}
addressSpace.isFrugal = false;
},
Re-enable normal mode (isFrugal = false) before adding nodes that need full bidirectional references (e.g., type instances, methods on objects clients will browse).
Sampling and update rate
A high-frequency setValueFromSource loop does not cause network traffic by itself. Network traffic is generated by monitored items that clients subscribed to. The relevant knobs:
minimumSamplingIntervalon each variable: a hint to the server about the fastest meaningful sample rate. Default is0(server samples on every change).- Subscription
publishingIntervaland monitored-itemsamplingInterval: negotiated per client. SetminimumSamplingIntervalto clamp these from below. - For variables that change at sub-millisecond rates, batch externally: write the value once per tick (e.g. every 50 ms) rather than firing
setValueFromSourcefrom a tight loop.
namespace.addVariable({
browseName: "FastSensor",
nodeId: "s=FastSensor",
dataType: "Double",
minimumSamplingInterval: 50, // 50 ms == 20 Hz
value: { dataType: "Double", value: 0 },
});
Cleaning up timers
If your boot path starts intervals or timeouts (sensor simulators, refresh ticks), register them with the address space inside onPopulate so the shutdown path cleans them up automatically:
onPopulate: (addressSpace, exposed) => {
const namespace = addressSpace.getOwnNamespace();
const sensor = namespace.addVariable({
organizedBy: "ObjectsFolder",
browseName: "Sensor",
nodeId: "s=Sensor",
dataType: "Double",
value: { dataType: "Double", value: 0 },
});
const timerId = setInterval(() => {
sensor.setValueFromSource({ dataType: "Double", value: Math.random() });
}, 250);
addressSpace.registerShutdownTask(() => clearInterval(timerId));
},
Without registerShutdownTask, timers survive shutdown and will fire against a dead server, throwing AddressSpace has been disposed errors on the next deploy.
Running multiple servers in one Node-RED instance
The bootstrap registry can hold more than one server, keyed by an optional ownerKey argument (default "default"). Two servers in the same Node-RED process are fine if you give them different keys and different ports:
const sterfive = global.get("sterfive");
const { bootstrapServer } = sterfive.bootstrap;
const handleAlpha = await bootstrapServer({ port: 4840, endpoint: "alpha", nodesets: ["standard"], onPopulate: () => {} }, "alpha");
const handleBeta = await bootstrapServer({ port: 4841, endpoint: "beta", nodesets: ["standard"], onPopulate: () => {} }, "beta");
Without distinct ownerKey values, the second call would see a config-hash mismatch on the same registry slot and tear alpha down before bringing beta up.
- Each binds a different port.
- Each is keyed by a different flow-context name (e.g.
$alphaHandle,$betaHandle).
Beyond ~5 servers per process, prefer separate Node-RED processes. The OPC UA stack is fine, but Node-RED's flow scheduling and the V8 event loop become the bottleneck before the OPC UA layer does.
What to monitor in production
The richest live signal comes from the server-info Function, which calls bootstrap.getServerInfo(handle.server) and returns:
traffic.currentChannelCount,currentSessionCount,currentSubscriptionCount— should track active clients.traffic.cumulatedSessionCountminuscurrentSessionCount— sessions that came and went; growing fast can indicate clients reconnecting in a loop.traffic.rejectedSessionCount,sessionAbortCount,sessionTimeoutCount— non-zero values indicate clients that fail to authenticate, drop mid-session, or stall.capabilities.maxSupportedSessions,recommendedMaxSupportedSubscriptions— the advertised ceilings; raise them inonPopulateif you saturate.certificates.rejectedFolder— if a client can't connect over a secure mode, their certificate likely landed here.
You can also read the same values via OPC UA itself under Server.ServerDiagnosticsSummary from any client.
Process-level shutdown hooks
When Node-RED runs as a service, Ctrl+C, systemctl stop, and container shutdown all deliver SIGINT or SIGTERM. The bootstrap helper registers its own SIGINT/SIGTERM/exit handlers on first call, so process exit is covered without any per-flow code.
Do not install your own process.on("SIGINT") handler from a Function node; it would race against both Node-RED's signal handling and the helper's hooks. For an explicit programmatic teardown from a flow, see Stopping the server explicitly.
Further reading
For more tips and examples, see the Sterfive book node-opcua by example.