1. Profiling with the Inspector & clinic.js
"This endpoint feels slow" is a hypothesis, not a diagnosis. Node ships a built-in inspector (the same V8 profiler Chrome DevTools uses) that gives you an exact breakdown of where CPU time was actually spent:
node --prof dist/server.js
# ... generate load against the server, then stop it ...
node --prof-process isolate-0x*.log > profile.txt
clinic.js wraps the same underlying tooling into a friendlier workflow that produces an interactive flame graph directly:
npx clinic flame -- node dist/server.js
# generate load with autocannon or a load test in another terminal, then Ctrl+C
# opens an interactive flame graph in your browser
A flame graph makes the "which function actually dominates CPU time" question visual — wide bars are functions where a lot of sampled time landed. Running it against real traffic patterns, not a synthetic microbenchmark, is what makes the result trustworthy: a function that looks expensive in isolation might contribute almost nothing to real request latency, and vice versa.
The function developers guess is slow is very often not the one that actually is — a profiler routinely surfaces the real bottleneck in a place nobody suspected (a logging call serializing a large object every request, a regex compiled fresh on every call instead of once at module load). Optimizing without profiling first risks spending real effort making an already-fast function marginally faster while the actual bottleneck goes untouched.
2. Detecting Event Loop Lag
Week 1 established that Node runs your JavaScript on a single thread, with the event loop scheduling callbacks between I/O operations. Event loop lag is the delay between when a callback (a timer, an I/O completion) should run and when it actually gets a chance to — and a growing lag means something is monopolizing the thread and starving every other in-flight request, not just the one doing the expensive work.
import { monitorEventLoopDelay } from "node:perf_hooks";
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
const p99Ms = histogram.percentile(99) / 1e6; // nanoseconds -> ms
if (p99Ms > 100) {
logger.warn({ p99Ms }, "Event loop lag exceeded 100ms");
}
histogram.reset();
}, 10_000);
A synchronous, CPU-heavy operation — a large JSON payload being parsed, a big array
being sorted, a regex with catastrophic backtracking — blocks the event loop for its
entire duration, and everything else queued behind it (every other request's
callbacks, every timer) simply waits. This is the same class of problem Week 9's
async/await discussion warned about from a different
angle: await only yields control at genuine I/O boundaries, and a
synchronous CPU-bound function has no await point to yield at, no
matter how it's wrapped.
new Promise((resolve) => resolve(heavyComputation())) still runs heavyComputation() synchronously, on the same thread, before the promise ever resolves — it just delays when the result becomes available, without freeing the event loop for anything else during that computation. Only genuine I/O, or offloading to a worker thread (Section 3), actually yields the thread.
3. Worker Threads for CPU-Bound Work
Node's built-in worker_threads module runs JavaScript on a genuinely
separate OS thread, with its own V8 instance and its own event loop, isolated from
the main thread's memory by default. This is Node's actual answer to CPU-bound work
— not clustering (Week 14, which forks separate processes to use multiple
cores for handling more concurrent requests), but a way to move one expensive,
synchronous computation off the thread that's serving everyone else.
import { parentPort, workerData } from "node:worker_threads";
function renderReport(rows: unknown[]): Buffer {
// CPU-heavy: aggregation + PDF rendering, no network or disk waiting involved
const aggregated = summarize(rows);
return renderPdf(aggregated);
}
const result = renderReport(workerData.rows);
parentPort!.postMessage(result);
import { Worker } from "node:worker_threads";
function generateReportOffThread(rows: unknown[]): Promise<Buffer> {
return new Promise((resolve, reject) => {
const worker = new Worker("./dist/workers/reportWorker.js", { workerData: { rows } });
worker.once("message", resolve);
worker.once("error", reject);
});
}
While renderReport runs inside the worker, the main thread's event
loop is completely free to keep handling every other request — the whole point of
moving it off-thread in the first place. Data passed via workerData and
postMessage is copied (or, for large binary data, can be efficiently
transferred) between threads rather than shared directly, since JavaScript objects
aren't safely shared across threads the way they can be read within one.
A worker pool (via a library like piscina, or hand-rolled) avoids the
overhead of spinning up a fresh worker thread — genuinely expensive relative to the
work itself — for every single request, reusing a fixed set of workers across many
jobs instead.
A slow database query or a slow downstream HTTP call doesn't need a worker thread — awaiting it already yields the event loop correctly (Week 9), and a worker thread adds pure overhead for no benefit there. Reach for a worker thread specifically when profiling (Section 1) or event loop lag monitoring (Section 2) points to genuine synchronous CPU work, not whenever something feels slow.
4. Hands-on Exercise
Profile a real bottleneck, then move CPU work off the main thread
Find and fix a real hotspot with profiling data, then measure the event loop's behavior directly.
Requirements:
- Profile a genuinely slow endpoint or script from an earlier week's project with
clinic flame, identify the function dominating the flame graph, and fix or optimize it based on that finding, not a guess. - Add the event loop lag monitor from Section 2 to your Express app, deliberately add a synchronous CPU-heavy route (a tight loop doing arithmetic for a few hundred milliseconds), and fire a request at a trivial, unrelated endpoint while it runs — confirm the trivial endpoint stalls.
- Move that CPU-heavy work into a worker thread using the pattern from Section 3, and repeat the same test — confirm the trivial endpoint now responds immediately while the worker computes in the background.
- Write a short note comparing worker threads (Section 3) against Week 14's cluster module: what problem does each actually solve?
Use a tight pure-JavaScript loop doing arithmetic as your CPU-heavy stand-in, not setTimeout or a real sleep — a timer-based delay is I/O-shaped, releases the event loop just fine, and will completely fail to demonstrate the blocking behavior you're trying to show.
5. Knowledge Check
Three quick questions. Expand each to check your answer.
Q1
Why does growing event loop lag affect every in-flight request, not just the one doing expensive work?
Why does growing event loop lag affect every in-flight request, not just the one doing expensive work?
Node runs all JavaScript callbacks on a single thread, so a synchronous, CPU-heavy operation blocks that thread for its entire duration — nothing else, including callbacks for completely unrelated requests, can run until it finishes. Event loop lag is a direct measurement of exactly that: how long other work has been stuck waiting behind whatever's currently monopolizing the thread.
Q2
Why doesn't wrapping a CPU-heavy function in a Promise make it non-blocking?
Why doesn't wrapping a CPU-heavy function in a Promise make it non-blocking?
A Promise's executor function still runs synchronously on the same thread, immediately, when the Promise is constructed — wrapping a function call in one doesn't move that work to another thread or yield control to the event loop during it. Only genuine I/O, or actually running the computation on a separate worker thread, frees the event loop while the work happens.
Q3
What's the difference between what Week 14's cluster module solves and what worker threads solve?
What's the difference between what Week 14's cluster module solves and what worker threads solve?
The cluster module forks multiple independent processes, each handling its own share of incoming requests, to use multiple CPU cores for overall request throughput. Worker threads move one specific, expensive, synchronous computation off the thread that's serving requests, so that computation doesn't block everything else on the same process — a different problem: total concurrent capacity versus not letting one heavy task starve everyone else on the same worker.