Preface
While extending Uptime Kuma with load balancing and node failover features, I unexpectedly hit a Node.js performance bottleneck:
The same code that ran 1,000 monitors smoothly on my home machine started to stutter at around 800 monitors per node when running on a 13-year-old test machine.
That forced me to step back and ask: did I write something bad, or is this a Node.js limitation?
Why does it start stuttering at 800 monitors?
Uptime Kuma is fundamentally a high-frequency polling system: every monitored item periodically sends a request, judges success or failure, and updates state. There's now an extra layer on top.
On newer CPUs these monitors can hold up, but on that 13-year-old test machine, at around 800 monitors the following symptoms appeared:
- The admin UI noticeably "lags for a moment" on operations
- Scheduled tasks fire later than configured
- Overall CPU usage approaches single-core saturation, but other cores sit idle
Before the root cause: a quick word on the JS event loop
JavaScript itself is single-threaded — all synchronous code must execute in order on the call stack. Once the main thread runs a long synchronous task (like heavy computation or an infinite loop), the entire thread is blocked and other tasks can't cut in.
In contrast, operations that need to wait — like setTimeout or HTTP requests — don't directly occupy the call stack. The runtime hands them off to lower-level APIs (libuv); when the task completes, the corresponding callback is queued, and the event loop picks it up and runs it when the call stack is idle.
That's why the event loop lets JavaScript, despite having only one main thread, schedule large amounts of I/O concurrently via async — without the program halting because it's waiting on external resources.
The root cause
When the number of monitors is large and polling frequency is high, a pile of "needs-to-recompute" logic stacks up on the same event loop. These computations aren't waiting on I/O — they really do consume CPU. Over time, they naturally become a performance bottleneck.
So without upgrading the single-core CPU, here are the practical approaches I've collected to lift performance:
Solution 1: Lower the polling frequency (adjust interval)
In a high-frequency polling architecture, many "complex schedules" are actually driven by polling. Lowering the polling frequency directly reduces how often CPU-bound logic fires, easing event-loop pressure.
Take Uptime Kuma: each monitor is a polling task. With 1,000 monitors and a 60-second interval, every minute you have 1,000 requests, success/failure judgments, status updates, and dashboard refreshes. These actions consume CPU. If the interval is too short, a swarm of "complex schedules" gets crammed into the same event loop in the same window. Stretching the polling interval reasonably (e.g., from 15s to 30s, or pushing less-critical monitors to longer intervals) directly cuts how often this CPU-bound logic fires, dropping event-loop pressure and reducing the lag.
Solution 2: Switch to multi-node (horizontal scaling of Uptime Kuma)
When single-machine resources are limited, distributing monitors across multiple nodes is more robust. Each node only handles a portion of monitors, with state unified through shared storage (DB/Redis), and a reverse proxy or task allocation layer in front.
Reference: I have a write-up on multi-node / Cluster approach here as further reading: Uptime Kuma Cluster Implementation Notes
Implementation points
- Shared storage: database/Redis as a single source of truth — avoid keeping state only in node memory.
- Task sharding: use tags, hashing, or queues to assign monitors to different nodes; avoid duplicate monitoring.
- Health checks and takeover: when a node fails, tasks should be taken over automatically by other nodes (Failover).
- Observability: centralized logs, metrics, and alerts for ops and faster issue localization.
Effects
- Single-machine pressure drops significantly; overall throughput and availability improve.
- Better fault isolation — a single node's problem won't drag down the whole service.
Solution 3: Use Bun as a high-performance Runtime
What is Bun?
While discussing Uptime Kuma with a colleague, he suggested I try Bun, so I dug in. Bun is essentially a high-performance JavaScript Runtime, with traits like:
- JIT / runtime designed for performance — both startup and execution efficiency are optimized
- Built-in bundler, test tools, and package manager (
bun install) - A degree of Node.js API compatibility, but not 100% (worth flagging)
Solution 4: Hand "complex schedules" to Worker Threads
Note: this section is my current research log — not yet implemented in the project. The content focuses on the approach and concept, not a finished solution.
The approach I've researched is targeting the "complex schedules" themselves — extracting the most CPU-hungry block and handing it to worker_threads.
Things suited for Worker Threads
- Recomputing each node's weight and health based on monitoring results
- Running complex rule judgments (e.g., multi-condition failover strategies)
- Batch analysis / sorting / statistics over many monitors
Conceptually: the main thread is responsible for:
- Receiving monitoring results
- Queueing / dispatching tasks to workers
- Receiving the worker's computed results and updating state
The truly heavy logic moves into the worker file.
Code example
A simplified scaffold (illustrative, not complete):
// main.js
const { Worker } = require("worker_threads");
function recalcLoadBalancing(monitors) {
return new Promise((resolve, reject) => {
const worker = new Worker("./recalc-worker.js", {
workerData: { monitors },
});
worker.on("message", (result) => resolve(result));
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`Worker exited: ${code}`));
});
});
}
// recalc-worker.js
const { parentPort, workerData } = require("worker_threads");
function heavyRecalc(monitors) {
// Run the complex, CPU-bound algorithm here
// Return new node weights / sort results / etc.
return { /* ... */ };
}
const result = heavyRecalc(workerData.monitors);
parentPort.postMessage(result);
The result:
- The main thread is no longer blocked by "computing load for 800 monitors at once"
- Even on old CPUs, UI lag is noticeably better
- To use more cores, just spin up more workers (with sensible limits)
Solution 5: Cluster / multiple Node processes to saturate multi-core
Note: this section covers research direction and feasibility analysis — not yet implemented in the live service.
If your service is a multi-user, multi-request Web API, beyond extracting complex schedules you can also use Cluster / multi-process to spread the load.
The idea is similar:
- Use
clusteror PM2 to run the same Node.js app as multiple processes - For a 4-core CPU, run 4 worker processes
- Place a reverse proxy in front (Nginx / HAProxy / Traefik) for load balancing
Cluster example
const cluster = require("cluster");
const os = require("os");
if (cluster.isMaster) {
const numCPUs = os.cpus().length;
console.log(`Master process ${process.pid} is running`);
console.log(`Forking ${numCPUs} workers...`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on("exit", (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died, restarting...`);
cluster.fork();
});
} else {
// Worker process — runs the actual application logic
require("./app.js");
console.log(`Worker ${process.pid} started`);
}
Summary
| Approach | When to use | Pros | Cons |
|---|---|---|---|
| Lower polling frequency (interval) | Pressure from high-frequency polling | Reduces complex-schedule frequency, lowers main-loop load | Slower reaction time, may miss brief outages |
| Multi-node (horizontal scale) | Single-machine resources limited, need to scale | Distributes load, fault isolation, easy to scale out | Cross-node sync and config complexity rises |
| Bun | New scripts, standalone services | Fast execution, fast startup | Compatibility isn't 100% yet |
| Worker Threads | CPU-bound complex schedules | Doesn't block main thread, can use multi-core | Need to handle data serialization |
| Cluster | Multi-request Web services | Multi-process load distribution, high fault tolerance | State requires shared storage |
If you're also debugging Node.js performance issues and stuck on an old machine, give the approaches above a try.




























Comments