Mark Ku's Blog
Podcast ConversationAI dialogue version of this article · Mandarin audio
Audio for this article is powered by VoAIVoAI

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 cluster or 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

ApproachWhen to useProsCons
Lower polling frequency (interval)Pressure from high-frequency pollingReduces complex-schedule frequency, lowers main-loop loadSlower reaction time, may miss brief outages
Multi-node (horizontal scale)Single-machine resources limited, need to scaleDistributes load, fault isolation, easy to scale outCross-node sync and config complexity rises
BunNew scripts, standalone servicesFast execution, fast startupCompatibility isn't 100% yet
Worker ThreadsCPU-bound complex schedulesDoesn't block main thread, can use multi-coreNeed to handle data serialization
ClusterMulti-request Web servicesMulti-process load distribution, high fault toleranceState requires shared storage

If you're also debugging Node.js performance issues and stuck on an old machine, give the approaches above a try.

Author

Mark Ku

擁有 10+ 年經驗的資深軟體工程師,現為 AI 應用 Builder,專注於大型平台架構與簡化複雜系統設計,從電商系統到訂閱與收費平台,結合 AI Agent、AI 整合與自動化開發,打造高效率且可持續演進的產品技術基礎。Read More

Found this useful?

The author's free tools, daily podcasts and newsletter are all here.

Mark Ku · This article is licensed under CC BY 4.0. Credit the author and link back to the original when reusing it.

Comments

Subscribe to Newsletter

Subscribe to get new posts delivered instantly — never miss a tech share.

By submitting, you agree to receive emails. You can anytime.

Popular Posts

View all
Mark Ku
··602

Oracle Cloud Always Free Tier: Linux Host and Static IP for a $0 Cloud Solution

Oracle Cloud Always Free Tier: Linux Host and Static IP for a $0 Cloud Solution
Mark Ku
··492

Say Goodbye to Postman's Fee Trap! A Hands-on Guide to Bruno, the Open-Source Git-Native API Testing Powerhouse.

Say Goodbye to Postman's Fee Trap! A Hands-on Guide to Bruno, the Open-Source Git-Native API Testing Powerhouse.
Mark Ku
··334

A Free, Open-Source, Notion-like Knowledge Base — A Complete Guide to Deploying and Backing Up Outline Wiki

A Free, Open-Source, Notion-like Knowledge Base — A Complete Guide to Deploying and Backing Up Outline Wiki
Mark Ku
··268

Training Your Own AI Voice: Hardware Requirements, Open-Source Model Comparison, and LoRA Fine-Tuning

Training Your Own AI Voice: Hardware Requirements, Open-Source Model Comparison, and LoRA Fine-Tuning
Mark Ku
··218

Building an Efficient API Management Platform: Deploying Kong Gateway from Scratch - Part 1

Building an Efficient API Management Platform: Deploying Kong Gateway from Scratch - Part 1
Mark Ku
··217

Setting Up Samba on Ubuntu to Share Folders with Windows 11

Setting Up Samba on Ubuntu to Share Folders with Windows 11
解決 Node.js 單執行緒效能瓶頸的幾種實戰解法 - Mark Ku's Tech Notes