Skip to main content
Node.js · lesson 17 of 20
Node.js advanced Lesson 17 of 20

Don't Block the Event Loop

Watch a CPU-bound hash route stall /healthz for seconds, then move the work to worker_threads so health checks stay fast while the same job keeps running.

By SkillByExample · Updated

nodejs worker_threads event-loop performance

Every route handler on a node:http server shares the same one JavaScript thread — a route that does synchronous CPU work keeps that thread busy, and every other request queued behind it, including an unrelated health check, waits for its turn. What Node.js Is showed this with a busy while loop delaying a timer; this lesson shows it with a real HTTP route, then fixes it with worker_threads. JavaScript Event Loop Explained covers the deeper microtask/macrotask ordering this builds on, and Graceful Shutdown and Health Checks covers what /healthz is for in the first place. Every command below runs in Docker’s node:26.13 image — no package.json, no install, only built-in node:http, node:crypto, and node:worker_threads modules.

A CPU-Bound Route Blocks /healthz on the Same Server

/hash computes 2,000,000 chained SHA-256 digests synchronously before responding; /healthz just replies {"status":"ok"} immediately — the only question is whether it actually gets a turn to:

// server-blocking.js
import { createServer } from "node:http";
import { createHash } from "node:crypto";

function hashWork(iterations) {
  let digest = "seed";
  for (let i = 0; i < iterations; i++) {
    digest = createHash("sha256").update(digest).digest("hex");
  }
  return digest;
}

const server = createServer((req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ status: "ok" }));
    return;
  }
  if (req.url === "/hash") {
    const digest = hashWork(2_000_000);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ digest }));
    return;
  }
  res.writeHead(404).end("not found\n");
});

server.listen(3000, () => console.log("listening on :3000"));

A client script measures this from inside the same container, over fetch, so the timing reflects the server’s own thread and not Docker’s host-port forwarding: it fires /hash without waiting for it, waits a fixed head start, then times a /healthz request against the 1000 ms threshold What Node.js Is already used for its own busy-loop timer:

// client-check.js
const base = "http://localhost:3000";

async function main() {
  const hashPromise = fetch(base + "/hash").then((r) => r.json());

  await new Promise((resolve) => setTimeout(resolve, 300));

  const start = Date.now();
  await fetch(base + "/healthz");
  const ms = Date.now() - start;
  console.log(ms > 1000 ? "SLOW: /healthz took more than 1000 ms while /hash was still running" : "FAST: /healthz took 1000 ms or less");

  const { digest } = await hashPromise;
  console.log("hash result:", digest);
}

main();
$ docker run -d --name nodejs-loop-blocking -v "$(pwd)":/app -w /app node:26.13 node server-blocking.js
bc1a1304fec4ba5cfd423ef131a4af54e5e6e2602e34cfd9e01220f622ab5361
$ until docker logs nodejs-loop-blocking 2>&1 | grep -q listening; do sleep 0.2; done
$ OUT=$(docker exec nodejs-loop-blocking node client-check.js); echo "$OUT"; DIGEST_BLOCKING=$(echo "$OUT" | grep -oE '[0-9a-f]{64}')
SLOW: /healthz took more than 1000 ms while /hash was still running
hash result: 41c96bc007df16f5b8d495fdc78a72d8ea7cf8c5f2846a562620e06030330c40
$ docker rm -f nodejs-loop-blocking
nodejs-loop-blocking

SLOW — /healthz’s own handler never runs until hashWork(2_000_000) returns, because both routes are plain callbacks on the same server, and createServer’s request handler only ever has one running at a time. The head start on /hash makes this deterministic: by the time client-check.js even starts timing /healthz, the hash computation is already well underway and has no opportunity to yield.

Moving the Work to worker_threads

The fix isn’t to make the hashing faster — it’s to run it somewhere that isn’t the thread /healthz also needs. worker_threads gives every Worker its own V8 instance and its own OS thread; worker-hash.js is the entire body of that thread, listening for one message and posting back one result:

// worker-hash.js
import { parentPort } from "node:worker_threads";
import { createHash } from "node:crypto";

parentPort.on("message", (iterations) => {
  let digest = "seed";
  for (let i = 0; i < iterations; i++) {
    digest = createHash("sha256").update(digest).digest("hex");
  }
  parentPort.postMessage({ digest });
});

server-worker.js keeps the exact same two routes — hashWork just moved into worker-hash.js, and /hash now awaits a Promise that resolves when the worker’s reply arrives:

// server-worker.js
import { createServer } from "node:http";
import { Worker } from "node:worker_threads";
import os from "node:os";

console.log("availableParallelism:", os.availableParallelism());

function runHashInWorker(iterations) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL("./worker-hash.js", import.meta.url));
    worker.once("message", (result) => {
      resolve(result);
      worker.terminate();
    });
    worker.once("error", reject);
    worker.postMessage(iterations);
  });
}

const server = createServer(async (req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ status: "ok" }));
    return;
  }
  if (req.url === "/hash") {
    const { digest } = await runHashInWorker(2_000_000);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ digest }));
    return;
  }
  res.writeHead(404).end("not found\n");
});

server.listen(3000, () => console.log("listening on :3000"));

Same client-check.js, same 2,000,000 iterations, same fixed head start — the only thing that changed is which thread runs hashWork:

$ docker run -d --name nodejs-loop-worker -v "$(pwd)":/app -w /app node:26.13 node server-worker.js
4846208fb53bacdaa06e5b5f105ef4f6583ec5b1f250c742194498af8bc53505
$ until docker logs nodejs-loop-worker 2>&1 | grep -q listening; do sleep 0.2; done
$ docker logs nodejs-loop-worker
availableParallelism: 12
listening on :3000
$ OUT=$(docker exec nodejs-loop-worker node client-check.js); echo "$OUT"; DIGEST_WORKER=$(echo "$OUT" | grep -oE '[0-9a-f]{64}')
FAST: /healthz took 1000 ms or less
hash result: 41c96bc007df16f5b8d495fdc78a72d8ea7cf8c5f2846a562620e06030330c40
$ docker rm -f nodejs-loop-worker
nodejs-loop-worker

FAST — and the digest is identical to the blocking version’s, which the next command checks directly instead of leaving it to the eye:

$ if [ "$DIGEST_BLOCKING" = "$DIGEST_WORKER" ]; then echo SAME; else echo DIFFERENT; fi
SAME

SAME — moving the loop into a Worker changed nothing about the answer, only which thread computed it. availableParallelism: 12 reports 12 logical CPUs available to this container, but that number isn’t why /healthz stayed fast: a Worker’s OS thread and the main thread get scheduled preemptively by the kernel, which can suspend a running thread to give another one a turn even when both threads are confined to the same single core — unlike Node’s own single JS thread, which only ever hands off control when a callback finishes or awaits. docker run --cpus=1 would not prove that: it only caps the container’s total CFS time quota to one CPU’s worth, so the main thread and the worker’s thread can still be scheduled onto two different physical cores at the same instant. --cpuset-cpus=0 is the flag that actually pins every thread in the container to one real core, leaving the kernel no second core to hide behind — repeating the exact same test with it confirms the claim:

$ docker run -d --name nodejs-loop-worker --cpuset-cpus=0 -v "$(pwd)":/app -w /app node:26.13 node server-worker.js
87d2e851e8644785fbf93203b310cf719091cc52bc30a8b64ff07cb2203c6423
$ until docker logs nodejs-loop-worker 2>&1 | grep -q listening; do sleep 0.2; done
$ docker logs nodejs-loop-worker
availableParallelism: 1
listening on :3000
$ docker exec nodejs-loop-worker node client-check.js
FAST: /healthz took 1000 ms or less
hash result: 41c96bc007df16f5b8d495fdc78a72d8ea7cf8c5f2846a562620e06030330c40
$ docker rm -f nodejs-loop-worker
nodejs-loop-worker

Still FAST, even pinned to a single real core. availableParallelism: 1 this time — unlike the 12 reported with no cpuset restriction, os.availableParallelism() reads the container’s actual CPU affinity, and --cpuset-cpus=0 leaves it exactly one core to see. The fix held anyway, which is the point: it never depended on a second core being free, only on the kernel being able to preempt the busy thread and give the main thread a turn.

A Real Failure: Passing a Function to a Worker

worker.postMessage() uses the structured clone algorithm to copy its argument into the worker’s own V8 instance — and a function has no data representation outside the engine that compiled it:

// clone-fail.js
import { Worker } from "node:worker_threads";

const worker = new Worker(new URL("./worker-hash.js", import.meta.url));

worker.postMessage({ onDone: () => console.log("done") });
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node clone-fail.js
node:internal/per_context/domexception:79
    const self = new Error();
                 ^

DOMException [DataCloneError]: () => console.log("done") could not be cloned.
    at new DOMException (node:internal/per_context/domexception:79:18)
    at Worker.postMessage (node:internal/worker:471:5)
    at file:///app/clone-fail.js:6:8
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:105:5)

Node.js v26.13.0

postMessage throws synchronously, before the message ever reaches the worker — the stack trace’s third frame points straight at clone-fail.js:6:8, the exact line that called it. Node exits with status 1 rather than leaving a half-started worker behind; this is a thrown DOMException, catchable with an ordinary try/catch, which Practice 2 below does.

What worker_threads Don’t Give You

A Worker isn’t free to create. Spawning 20 of them, one at a time, each for a trivial single-iteration job, still costs real wall time just for the thread and V8 instance to come up and tear down:

// worker-startup-cost.js
import { Worker } from "node:worker_threads";

function spawnAndWait(iterations) {
  return new Promise((resolve) => {
    const worker = new Worker(new URL("./worker-hash.js", import.meta.url));
    worker.once("message", () => {
      worker.terminate();
      resolve();
    });
    worker.postMessage(iterations);
  });
}

const start = Date.now();
for (let i = 0; i < 20; i++) {
  await spawnAndWait(1);
}
const ms = Date.now() - start;
console.log(ms > 200 ? "SLOWER_THAN_200MS: 20 workers, one at a time, cost more than 200 ms just to start and stop" : "UNDER_200MS");
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node worker-startup-cost.js
SLOWER_THAN_200MS: 20 workers, one at a time, cost more than 200 ms just to start and stop

20 workers doing almost no work still took more than 200 ms, entirely in startup and shutdown overhead — a Worker is a poor fit for small, frequent jobs. And worker_threads only ever moves synchronous CPU work off the main thread: a slow database query or a slow upstream HTTP call is already asynchronous in Node, handled by libuv without blocking anything, so wrapping it in a Worker gives it nothing to speed up. A Worker also shares its creating process’s filesystem and network access — it is not a security sandbox.

Practice

1. Lower server-blocking.js’s iteration count to 50,000 — well under the 1000 ms threshold — and confirm client-check.js now reports FAST even without moving the work to a worker.

Solution
// server-blocking-small.js (iteration count lowered under the threshold)
import { createServer } from "node:http";
import { createHash } from "node:crypto";

function hashWork(iterations) {
  let digest = "seed";
  for (let i = 0; i < iterations; i++) {
    digest = createHash("sha256").update(digest).digest("hex");
  }
  return digest;
}

const server = createServer((req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ status: "ok" }));
    return;
  }
  if (req.url === "/hash") {
    const digest = hashWork(50_000); // was: 2_000_000
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ digest }));
    return;
  }
  res.writeHead(404).end("not found\n");
});

server.listen(3000, () => console.log("listening on :3000"));

client-check.js is unchanged — it only ever calls /hash and /healthz, never caring how much work either one does:

$ docker run -d --name nodejs-loop-blocking-small -v "$(pwd)":/app -w /app node:26.13 node server-blocking-small.js
893be7da2a8955dc2baeb89e258f7d971f2636ae09a28a7fd8907a38302a54a0
$ until docker logs nodejs-loop-blocking-small 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec nodejs-loop-blocking-small node client-check.js
FAST: /healthz took 1000 ms or less
hash result: c4a1bcd6652557986336bf090d76c66f85209fcfa9927facebf31d84df12b646
$ docker rm -f nodejs-loop-blocking-small
nodejs-loop-blocking-small

FAST, even with every route still running on the one main thread. The stall in the main example was never about having two routes on one server — it was specifically about how long /hash kept that one thread busy. 50,000 iterations finish quickly enough that /healthz never has to wait for a meaningful amount of time.

2. Catch clone-fail.js’s DataCloneError instead of letting it crash the process, then send the same worker a real, cloneable payload and confirm it still replies.

Solution
// clone-recover.js (catches the error and keeps using the worker)
import { Worker } from "node:worker_threads";

const worker = new Worker(new URL("./worker-hash.js", import.meta.url));

worker.once("message", (result) => {
  console.log("worker replied:", JSON.stringify(result));
  worker.terminate();
});

try {
  worker.postMessage({ onDone: () => console.log("done") });
} catch (err) {
  console.log("caught:", err.name + ": " + err.message);
  console.log("worker is still alive, sending a real payload instead");
  worker.postMessage(1000);
}
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node clone-recover.js
caught: DataCloneError: () => console.log("done") could not be cloned.
worker is still alive, sending a real payload instead
worker replied: {"digest":"81b2433a7cd4602eaa9deb314fb6a0d2a5006b42c5a72273bb72c780e181d343"}

err.message is exactly the text after the error class in the uncaught version’s stack trace — wrapping the call in try/catch turns the crash into two ordinary log lines. The worker object itself was never affected by the failed postMessage: the clone check runs and fails before anything is sent, so the worker that’s still sitting there, never having received a message, happily processes the very next postMessage(1000) and replies.

3. Change worker-hash.js to accept a plain object instead of a raw number, and confirm postMessage with a tagged object clones successfully — no DataCloneError for an ordinary object.

Solution
// worker-hash-obj.js (accepts an object instead of a raw number)
import { parentPort } from "node:worker_threads";
import { createHash } from "node:crypto";

parentPort.on("message", ({ iterations, tag }) => {
  let digest = "seed";
  for (let i = 0; i < iterations; i++) {
    digest = createHash("sha256").update(digest).digest("hex");
  }
  parentPort.postMessage({ tag, digest });
});
// clone-object.js
import { Worker } from "node:worker_threads";

const worker = new Worker(new URL("./worker-hash-obj.js", import.meta.url));

worker.once("message", (result) => {
  console.log("worker replied:", JSON.stringify(result));
  worker.terminate();
});

worker.postMessage({ iterations: 1000, tag: "practice-3" });
$ docker run --rm -v "$(pwd)":/app -w /app node:26.13 node clone-object.js
worker replied: {"tag":"practice-3","digest":"81b2433a7cd4602eaa9deb314fb6a0d2a5006b42c5a72273bb72c780e181d343"}

No error at all — { iterations: 1000, tag: "practice-3" } is a plain object made of numbers and strings, every one of which the structured clone algorithm copies without issue. The digest is identical to Practice 2’s 1000-iteration result, because both ultimately ran the same loop with the same seed; only the shape of the message changed.

4. Let /hash take an iteration count from a query string (?n=) instead of a fixed number, and confirm /healthz is still FAST against a job twice the size of the main example’s.

Solution
// server-worker-n.js (/hash takes a ?n= query param instead of a fixed count)
import { createServer } from "node:http";
import { Worker } from "node:worker_threads";

function runHashInWorker(iterations) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL("./worker-hash.js", import.meta.url));
    worker.once("message", (result) => {
      resolve(result);
      worker.terminate();
    });
    worker.once("error", reject);
    worker.postMessage(iterations);
  });
}

const server = createServer(async (req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ status: "ok" }));
    return;
  }
  if (req.url.startsWith("/hash")) {
    const n = Number(new URL(req.url, "http://localhost").searchParams.get("n")) || 2_000_000;
    const { digest } = await runHashInWorker(n);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ n, digest }));
    return;
  }
  res.writeHead(404).end("not found\n");
});

server.listen(3000, () => console.log("listening on :3000"));
// client-check-n.js (requests /hash?n=4000000 instead of the default-size job)
const base = "http://localhost:3000";

async function main() {
  const hashPromise = fetch(base + "/hash?n=4000000").then((r) => r.json());

  await new Promise((resolve) => setTimeout(resolve, 300));

  const start = Date.now();
  await fetch(base + "/healthz");
  const ms = Date.now() - start;
  console.log(ms > 1000 ? "SLOW: /healthz took more than 1000 ms while /hash was still running" : "FAST: /healthz took 1000 ms or less");

  const { n, digest } = await hashPromise;
  console.log("hash result for n =", n, ":", digest);
}

main();
$ docker run -d --name nodejs-loop-worker-n -v "$(pwd)":/app -w /app node:26.13 node server-worker-n.js
2bc1a66e604f1272d45fe5fa3f559d9d74170ee4429face99fd0b93b8d73a805
$ until docker logs nodejs-loop-worker-n 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec nodejs-loop-worker-n node client-check-n.js
FAST: /healthz took 1000 ms or less
hash result for n = 4000000 : c3207c526b299774b2a607ad3964dbe13df62722afa2969138227453697981c9
$ docker rm -f nodejs-loop-worker-n
nodejs-loop-worker-n

Still FAST at twice the main example’s iteration count. /healthz’s response time was never tied to how much work /hash does — it only depends on whether that work runs on the main thread or somewhere else, which n doesn’t change either way.

Frequently Asked Questions

Why does a slow route on one path delay a completely unrelated route like /healthz?
Node.js runs all of your JavaScript — every route handler of a given server — on one thread. A route that computes something CPU-heavy synchronously keeps that thread busy until the computation returns, and nothing else queued behind it (including an unrelated /healthz handler on the same server) gets a turn until it's free. This is the same one-callback-at-a-time model What Node.js Is (/nodejs/what-is-nodejs/) demonstrates with a busy while loop delaying a 0 ms timer; a synchronous route handler is just another thing occupying that one thread.
Why does moving the hash computation into a worker_threads Worker fix /healthz without needing extra CPU cores?
A Worker runs on its own operating system thread, and the OS schedules threads preemptively — it can switch away from a busy thread and give another thread (the main thread, running /healthz) a turn, even on a single physical core. That's different from Node's own single JS thread, which only ever hands off control when the running callback finishes or awaits. Testing the fixed server with docker run --cpuset-cpus=0, which pins every thread in the container to one real core (not --cpus=1, which only caps a CFS time quota and still lets threads land on different cores), confirms /healthz still answers in well under 1000 ms.
What exactly can't be passed to worker.postMessage()?
Anything the structured clone algorithm can't serialize: functions, symbols, and a handful of other types that have no data representation outside the running JS engine. The exact failure is a DOMException named DataCloneError — in this lesson's case, () => console.log("done") could not be cloned. Plain objects, arrays, numbers, and strings all clone fine, which is why this lesson's working examples pass iteration counts and tagged objects without issue.
Does moving CPU work into a worker_threads Worker make a slow database query or a slow network call faster?
No. Those are already asynchronous in Node — the wait happens off the main thread via libuv, not by blocking it — so a Worker gives them nothing to speed up. worker_threads only helps synchronous, CPU-bound code that would otherwise occupy the main thread end to end, like this lesson's hashing loop. Each Worker is also a full second V8 instance, so spawning one costs real memory and startup time, and none of this is a security sandbox — a Worker shares the same process and the same filesystem and network access as the thread that created it.