AdvancedTypeScript · Lesson 4 of 10

The Event Loop, Concurrency & Worker Threads

How Node.js schedules work, why CPU-heavy code blocks it, and how to fix that.

Node.js runs JavaScript on one main thread driven by the event loop. After the current code finishes, Node runs all queued microtasks (process.nextTick, then Promise callbacks), then moves through the loop's phases: timers (setTimeout), I/O callbacks, setImmediate, and so on.

I/O (network, disk) is handled outside the main thread, so thousands of concurrent requests are cheap. But CPU-heavy JavaScript — big loops, image processing, hashing large data — blocks the loop, and every other request waits.

Fix CPU-bound work by moving it to a worker_threads Worker, splitting it into chunks, or moving it to a separate service. Measure event-loop delay with perf_hooks.monitorEventLoopDelay in production.

event-loop.tsTypeScript
function demo(): void {
  console.log("1. sync start");

  setTimeout(() => console.log("6. setTimeout (next timers phase)"), 0);
  setImmediate(() => console.log("5. setImmediate (check phase)"));
  Promise.resolve().then(() => console.log("4. promise microtask"));
  process.nextTick(() => console.log("3. nextTick (before promises)"));

  console.log("2. sync end");
}

// Run inside a timer callback so the order is always 1 → 6. (At the top level
// of an ES module, promises can run before nextTick, because the module itself
// is evaluated as a promise job.)
setTimeout(demo, 0);
worker.tsTypeScript
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
import { fileURLToPath } from "node:url";

function countPrimes(limit: number): number {
  let count = 0;
  for (let n = 2; n <= limit; n++) {
    let prime = true;
    for (let d = 2; d * d <= n; d++) {
      if (n % d === 0) {
        prime = false;
        break;
      }
    }
    if (prime) count++;
  }
  return count;
}

if (isMainThread) {
  const worker = new Worker(fileURLToPath(import.meta.url), {
    workerData: 5_000_000,
    execArgv: ["--import", "tsx"],
  });
  const ticker = setInterval(() => console.log("main thread still responsive"), 200);
  worker.on("message", (count: number) => {
    clearInterval(ticker);
    console.log("Primes found:", count);
  });
  worker.on("error", (err) => console.error(err));
} else {
  parentPort?.postMessage(countPrimes(workerData as number));
}

Key points

  • Microtasks (nextTick, Promises) run before timers and I/O callbacks.
  • Async I/O scales well; CPU-heavy JavaScript blocks every request.
  • Offload CPU-bound work to worker threads or separate services.

Exercise

Write a server route that computes a large Fibonacci number synchronously and observe how another route becomes slow while it runs. Then move the computation into a Worker and compare response times.

Show solution

Try the exercise yourself first — then compare your approach with this one.

While /fib-sync computes, the event loop is blocked, so even /ping has to wait. /fib-worker runs the same calculation in a worker thread, and /ping keeps answering instantly. Try curl localhost:3000/fib-sync and, at the same time, curl localhost:3000/ping in another terminal.

fib-server.tsTypeScript
import { createServer } from "node:http";
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
import { fileURLToPath } from "node:url";

function fib(n: number): number {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

function fibInWorker(n: number): Promise<number> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(fileURLToPath(import.meta.url), { workerData: n, execArgv: ["--import", "tsx"] });
    worker.once("message", resolve);
    worker.once("error", reject);
  });
}

if (isMainThread) {
  createServer(async (req, res) => {
    const started = performance.now();
    let body: string;
    if (req.url === "/fib-sync") body = String(fib(40));                 // blocks everyone
    else if (req.url === "/fib-worker") body = String(await fibInWorker(40));   // main thread stays free
    else body = "pong";
    res.end(body + " (" + Math.round(performance.now() - started) + " ms)\n");
  }).listen(3000, () => console.log("Try /fib-sync, /fib-worker and /ping on port 3000"));
} else {
  parentPort?.postMessage(fib(workerData as number));
}

Check your understanding

  1. In which order do these log? setTimeout(A, 0), Promise.resolve().then(B), console.log(C)

  2. Why does a CPU-heavy loop in a request handler slow down every other request?

  3. Which is the best fix for an expensive calculation inside a Node.js server?

  4. What does process.nextTick schedule its callback relative to Promise callbacks (in CommonJS code)?

Ask AI