SuitsLearn LogoSuitsLearn

Node.js

Part 4 — Advanced Event Loop Patterns and Alternatives

The final part of the event loop series: scaling Node.js beyond a single process with Cluster, Child Processes, message queues, background jobs, and distributed processing — and an honest look at when Node.js isn't the right tool at all.

By Ishan Wickremasuriya

Published 2026-06-2016 min read

Backend EngineeringDistributed SystemsEvent LoopJavaScript RuntimeNode.jsProduction SystemsSoftware ArchitectureSystem Design

Three parts into this series, the pattern has been consistent: every optimization technique — chunking, Worker Threads, streams, queues — has been about making a single Node.js process handle more work without blocking its single event loop.

That ceiling is real. A single process, no matter how well optimized, runs on a single machine and is fundamentally bounded by what one event loop and one set of CPU cores can do. This final part is about what happens when you need to go past that ceiling — and, just as importantly, about being honest with yourself about when Node.js stops being the right tool for the job entirely.


Worker Threads, Revisited

Part 3 covered Worker Threads as a way to move CPU-bound work off the main thread within a single process. Here, it's worth placing them on a map alongside the other options in this post, because the four approaches below are often confused with each other.

                    Concurrency Options in Node.js

  ┌───────────────────┬──────────────────┬───────────────────┬──────────────────┐
  │  Worker Threads   │     Cluster      │ Child Processes   │  Message Queues  │
  ├───────────────────┼──────────────────┼───────────────────┼──────────────────┤
  │ Same process      │ Multiple         │ Separate OS       │ Separate         │
  │ Shared memory     │ processes,       │ processes,        │ services,        │
  │ possible (        │ same machine     │ possibly          │ possibly         │
  │ SharedArrayBuffer)│                  │ different runtime │ different        │
  │                   │                  │                   │ machines         │
  ├───────────────────┼──────────────────┼───────────────────┼──────────────────┤
  │ Best for:         │ Best for:        │ Best for:         │ Best for:        │
  │ CPU-bound JS      │ Using all cores  │ Running non-Node  │ Decoupling work  │
  │ work within one   │ for an HTTP      │ tools/languages,  │ from the request │
  │ process           │ server           │ strict isolation  │ lifecycle        │
  └───────────────────┴──────────────────┴───────────────────┴──────────────────┘

The distinction that matters most: Worker Threads scale within a process, on one machine. Everything else in this post scales across processes — and eventually across machines.


Cluster

A single Node.js process uses one CPU core. On a modern server with 8 or 16 cores, that means most of the machine's capacity sits idle unless something explicitly takes advantage of it.

Node's built-in cluster module solves this by forking multiple copies of the process, each with its own event loop, with the OS distributing incoming connections across them.

const cluster = require("cluster");
const os = require("os");

if (cluster.isPrimary) {
  const numCPUs = os.cpus().length;

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker, code) => {
    console.log(`Worker ${worker.process.pid} died, restarting`);
    cluster.fork(); // replace the dead worker
  });
} else {
  // This code runs in each worker process
  require("./app.js").listen(3000);
}
                    Cluster Architecture

                  ┌──────────────────┐
                  │  Primary Process │
                  │  (no app logic)  │
                  └────────┬─────────┘
                           │ forks
        ┌──────────────────┼──────────────────┐
        v                  v                  v
  ┌───────────┐      ┌───────────┐      ┌───────────┐
  │ Worker 1  │      │ Worker 2  │      │ Worker 3  │
  │ Own event │      │ Own event │      │ Own event │
  │ loop      │      │ loop      │      │ loop      │
  └───────────┘      └───────────┘      └───────────┘
        ▲                  ▲                  ▲
        └──────────────────┴──────────────────┘
                   OS distributes connections

This is genuinely effective for CPU utilization — an 8-core machine running 8 worker processes can handle roughly 8x the request throughput of a single process, for request-handling workloads that don't share much state.

The catch: no shared memory between workers. Each worker has its own heap, its own variables, its own in-memory cache if you have one. An in-memory rate limiter or cache that works perfectly in a single-process app will behave inconsistently under Cluster, because each of the 8 workers has its own independent copy.

// This works fine with one process. With Cluster, each worker
// has its own separate inMemoryCache — they don't share data.
const inMemoryCache = new Map();

The fix is to move shared state somewhere genuinely shared — Redis, a database, or a dedicated cache service — rather than relying on process memory once Cluster (or any multi-process deployment) is in play.

In most production deployments today, this same effect is achieved at the infrastructure level instead — running multiple container replicas behind a load balancer (Kubernetes, ECS, etc.) rather than using the cluster module directly inside one machine. The underlying tradeoff is identical either way: more throughput, no shared in-process memory.


Child Processes

Cluster forks copies of the same Node.js application. Child Processes are more general — spawning any executable, written in any language, as a separate OS process that communicates with the parent over stdin/stdout or a dedicated channel.

const { spawn } = require("child_process");

app.post("/convert-video", (req, res) => {
  const ffmpeg = spawn("ffmpeg", [
    "-i", req.body.inputPath,
    "-vcodec", "libx264",
    req.body.outputPath,
  ]);

  ffmpeg.on("close", (code) => {
    res.json({ success: code === 0 });
  });
});

This is the right tool when the work genuinely doesn't belong in JavaScript at all — video encoding via ffmpeg, machine learning inference via a Python script, image processing via ImageMagick. Rather than trying to replicate that functionality in Node.js (often poorly, or not at all), Child Processes let Node.js act as an orchestrator, delegating to the tool actually built for the job.

const { spawn } = require("child_process");

function runPythonScript(args) {
  return new Promise((resolve, reject) => {
    const python = spawn("python3", ["./ml_inference.py", ...args]);
    let output = "";

    python.stdout.on("data", (data) => { output += data; });
    python.on("close", (code) => {
      code === 0 ? resolve(JSON.parse(output)) : reject(new Error(`Exit code ${code}`));
    });
  });
}

The cost is real: process spawn overhead, serialization across the process boundary, and the operational complexity of managing a process that isn't Node.js inside a Node.js deployment. This is justified when the alternative is reimplementing mature, specialized tooling in JavaScript — not as a general-purpose performance technique.


Message Queues

Part 3 introduced queue-based architecture using Bull, backed by Redis, running within a single deployment. At larger scale, message queues become the backbone connecting entirely separate services, potentially on entirely separate machines.

                  Distributed Queue Architecture

  ┌──────────────┐        ┌───────────────┐        ┌────────────────┐
  │  API Service │  ──>   │ Message Queue │  ──>   │ Worker Service │
  │  (Node.js)   │        │ (RabbitMQ /   │        │ (Node.js, or   │
  │              │        │  SQS / Kafka) │        │  any language) │
  └──────────────┘        └───────────────┘        └────────────────┘
        │                                                  │
        v                                                  v
  Accepts requests,                                 Processes jobs,
  responds immediately                              independently scaled

The architectural shift from Part 3's in-process queue is significant: the producer and consumer no longer need to be the same application, the same language, or the same machine. This unlocks:

  • Independent scaling — if job processing is the bottleneck, scale worker instances without touching the API tier at all
  • Language flexibility — a Node.js API can enqueue work that a Python or Go worker processes
  • Resilience — if the worker fleet goes down temporarily, jobs queue up and process once it recovers, rather than failing outright
// Producer (Node.js API)
const { SQSClient, SendMessageCommand } = require("@aws-sdk/client-sqs");

const sqs = new SQSClient({ region: "us-east-1" });

app.post("/orders", async (req, res) => {
  await sqs.send(new SendMessageCommand({
    QueueUrl: process.env.ORDER_QUEUE_URL,
    MessageBody: JSON.stringify(req.body),
  }));

  res.status(202).json({ status: "accepted" });
});

The tradeoff is operational complexity: message ordering guarantees (or lack thereof), at-least-once vs. exactly-once delivery semantics, dead-letter queues for failed jobs, and monitoring across now-separate services all become real concerns that a single in-process queue didn't have to deal with.


Background Jobs

Not all deferred work arrives via an HTTP request. Scheduled and recurring work — nightly reports, cache warming, cleanup tasks — needs its own pattern, separate from the request/response cycle entirely.

const cron = require("node-cron");

cron.schedule("0 2 * * *", async () => {
  console.log("Running nightly report generation");
  await generateAllReports();
});

For work that needs to survive a process restart, or that needs visibility and retry logic, this is usually combined with the queue pattern from Part 3 — a scheduler enqueues jobs, and a worker pool (potentially the same one handling request-triggered jobs) processes them.

Cron trigger → Enqueue job → Worker picks up job → Job processed
                                       |
                              (same worker pool as request-triggered jobs)

The key architectural decision here is keeping scheduled work entirely separate from the request-serving event loop — a nightly report that takes ten minutes to generate should never be competing with live user traffic for the same call stack.


Distributed Processing

At the largest scale, the unit of work outgrows not just one event loop, but one machine's worth of compute entirely — splitting a single logical task across many machines that coordinate to produce one result.

                Distributed Processing

  Large Dataset
        │
        v
  ┌───────────────────────────────┐
  │      Coordinator/Splitter     │
  └──────┬─────────┬─────────┬────┘
         v         v         v
   ┌──────────┐ ┌──────────┐ ┌──────────┐
   │ Worker A │ │ Worker B │ │ Worker C │
   │ Machine 1│ │ Machine 2│ │ Machine 3│
   └────┬─────┘ └────┬─────┘ └────┬─────┘
        v            v            v
   ┌──────────────────────────────────┐
   │         Aggregator               │
   └──────────────────────────────────┘
                  │
                  v
            Final Result

Node.js can participate in this kind of architecture — as the coordinator, as individual workers, or both — but it's rarely the tool doing the heavy lifting at this scale. Frameworks built for distributed data processing ( Apache Spark, Hadoop, or managed equivalents) exist specifically because coordinating retries, partial failures, and data partitioning across many machines is a hard problem with its own established solutions. Node.js's role here is usually orchestration — triggering jobs, tracking status, aggregating results — rather than the distributed computation itself.

This is worth naming explicitly because it's a common over-engineering trap: reaching for distributed processing for a dataset that a single well-optimized process (using the techniques from Part 3) could handle just fine. Distributed processing solves a real problem — but it's the right tool only once a single machine's capacity, properly used, genuinely isn't enough.


When Node.js Is Not the Right Tool

Every part of this series has operated from the same implicit assumption: that the goal is to make Node.js handle a given workload well. That assumption deserves an honest exception.

Heavy, sustained CPU-bound computation. Worker Threads and Cluster help, but Node.js (via V8) is not the fastest environment for pure numerical computation. Languages like Go, Rust, or C++ — or specialized tools like NumPy in Python — will outperform Node.js for things like large-scale numerical simulation, video encoding at scale, or scientific computing, often significantly. Node.js can orchestrate calls to these tools (via Child Processes or service boundaries); it doesn't need to do the computation itself.

True low-level concurrency at extreme scale. Languages built around lightweight, massively concurrent execution — Go's goroutines, Elixir/Erlang's actor model — are designed from the ground up for problems like handling millions of simultaneous long-lived connections (chat servers, real-time multiplayer systems). Node.js can do this reasonably well, but it's not the architecture's first language of choice for that specific shape of problem.

Strong typing and compile-time guarantees at large scale. As a codebase and team grow, the absence of a type system (without TypeScript layered on top, which helps significantly but isn't the same as a compiled language's guarantees) can become a real cost. This isn't unique to Node.js — it's a JavaScript-ecosystem consideration — but it's a legitimate factor in technology choice for large, long-lived systems.

Workloads with mature, dominant tooling elsewhere. Data science and machine learning training have Python's ecosystem (PyTorch, TensorFlow, pandas) so far ahead in tooling maturity that building equivalent work natively in Node.js is rarely the pragmatic choice, even though JavaScript ML libraries exist.

None of this is an argument against Node.js generally — its strengths in this exact series (I/O-bound work, high connection counts, a single coherent language across frontend and backend) are real and valuable. It's an argument for choosing tools based on the shape of the actual problem, rather than defaulting to whatever's already in the stack. The most expensive architecture decisions are usually the ones made by default rather than by evaluation.


Summary: Part 4

ApproachScales WithinShared StateBest For
Worker ThreadsOne processPossible via SharedArrayBufferCPU-bound JS work
ClusterOne machineNone — separate memory per workerUsing all CPU cores for request handling
Child ProcessesOne machine (typically)None — communicates via stdin/stdoutDelegating to non-Node tools
Message QueuesMany machinesNone — explicit messages onlyDecoupling producers from consumers
Background JobsWherever workers runDepends on backing queueScheduled/recurring work
Distributed ProcessingMany machinesExplicit coordination requiredDatasets beyond one machine's capacity

Closing the Series

Four parts ago, this series started with a simple question: why does Node.js need an event loop at all? The answer was about avoiding the cost of one-thread-per-request — and everything since has been a deeper look at the consequences of that original design decision.

Part 1 built the foundation: the call stack, libuv, the event loop's phases, and the distinction between microtasks and macrotasks. Part 2 took that foundation and used it to recognize danger in ordinary-looking code — CPU work disguised as async code, synchronous APIs, regex traps, large loops, memory pressure. Part 3 turned recognition into measurement and remediation — event loop lag, profiling, streams, backpressure, batching, and queues. This part has been about the ceiling on all of that — what happens when one process, however well optimized, isn't enough, and when Node.js itself isn't the right answer.

The thread running through all four parts is the same: Node.js is fast not because it does more, but because it's careful about what it makes the call stack wait for. Every technique in this series — from understanding setTimeout's actual behavior in Part 1, to spotting a blocking JSON.stringify in Part 2, to reading a flame graph in Part 3, to deciding between Cluster and a message queue here — is really the same question asked at a different scale: what is currently occupying the thing that can only do one thing at a time, and does it need to be?

That question doesn't go away as systems grow. It just gets asked about bigger and bigger units — a function, a process, a machine, a fleet. Understanding it at the smallest scale, the way Part 1 did, is what makes the larger-scale decisions in this final part legible at all.