Crux Read real Node handlers and a perf signal, predict how each interacts with the event loop, and pick the fix a senior engineer reaches for first.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
Blocking is diagnosed in handlers and in the lag histogram, not in the abstract. Read each snippet, predict what it does to the single loop thread, and choose the change a senior engineer would make before reaching for any knob.
Goal
Practise the loop you run in every freeze incident: spot the synchronous span or the unbounded fan-out on the hot path, name why it stalls the loop, and reach for the highest-leverage fix — async API, worker thread, or concurrency cap.
Snippet 1 — the synchronous login
import bcrypt from "bcrypt";import fs from "fs";app.post("/login", (req, res) => { const policy = fs.readFileSync("./password-policy.json", "utf8"); // sync read const hash = bcrypt.hashSync(req.body.password, 12); // ~250 ms CPU // ...verify and respond...});
Quiz
Completed
Under login load this handler tanks throughput for every route, not just /login. What is happening, and the highest-leverage fix?
Heads-up Cost factor 12 is a reasonable security choice; the bug is running it synchronously on the loop, not the factor. Async bcrypt offloads the same work to the libuv pool and keeps the loop free.
Heads-up Hashing is CPU work, but async bcrypt runs it on a libuv pool thread so the loop keeps serving other requests. Blocking the loop is a choice, not a necessity.
Heads-up The sync calls do complete inline; correctness is fine. The defect is that they monopolise the single loop thread, head-of-line-blocking every other request.
Snippet 2 — the resize on the libuv pool
// Team's "fix" after profiling a slow image endpoint:process.env.UV_THREADPOOL_SIZE = "32";app.post("/resize", (req, res) => { const out = resizeImageSync(req.body.buffer, 1024, 768); // pure-JS pixel loop res.send(out);});
Quiz
Completed
The team raised UV_THREADPOOL_SIZE to 32 expecting the resize to parallelise. It changed nothing. Why, and what is the correct fix?
Heads-up Even if it took effect, the pool does not execute JavaScript, so it could never run the JS pixel loop. The fix is a worker thread, not the pool.
Heads-up Over-provisioning the pool does cause contention, but that is not why this is broken — the pool never runs JS at all. The resize needs a worker thread.
Heads-up A pixel loop is CPU-bound, not I/O; that is exactly why it blocks the loop and why a worker thread (or chunking) is the answer.
Snippet 3 — the worker-pool starvation
// Worker pool sized to the machine's 4 cores:const pool = new WorkerPool({ size: 4 });app.get("/report/:id", async (req, res) => { // Each report = one CPU-heavy aggregation task on the pool. const result = await pool.run("aggregate", req.params.id); // may take ~2 s res.json(result);});
Quiz
Completed
Under a burst of report requests, every endpoint — including cheap ones that also use the pool — sees latency climb into seconds, then time out. What is the failure mode?
Heads-up Nothing here leaks; the symptom is queueing, not growth. A fixed pool of 4 simply cannot run more than 4 heavy tasks at once, so the rest wait.
Heads-up await yields the loop; the loop stays free. The bottleneck is the bounded set of workers, not the loop thread.
Heads-up More workers than cores does not add CPU; 64 workers on 4 cores just time-slice and add overhead. You need bounded queueing with timeouts/shedding, and ideally more cores or instances.
Snippet 4 — measuring the freeze
import { monitorEventLoopDelay } from "node:perf_hooks";const h = monitorEventLoopDelay();h.enable();setInterval(() => { console.log("loop delay p99 (ms):", h.percentile(99) / 1e6);}, 1000);// Sample output during a bad reporting request:// loop delay p99 (ms): 812
Quiz
Completed
CPU sits at a calm ~55% while this prints a p99 loop delay of 812 ms. Which reading is correct?
Heads-up CPU being moderate is exactly the trap: one fat synchronous span can stall the loop while CPU stays middling. 812 ms of p99 lag is a real, user-visible freeze.
Heads-up Loop delay is how late a scheduled callback fired — the gap before the loop reached it — not per-request latency. It quantifies how long the loop was monopolised.
Heads-up More cores do not unblock a single loop thread; one Node loop is one core's worth of JS. The fix is finding and offloading the synchronous span, then re-measuring lag.
Recap
Every freeze is read in handlers and in the lag histogram: synchronous I/O and sync crypto on the request path stall the loop and must move to async APIs (which use the libuv pool); CPU-bound JS cannot be helped by a bigger libuv pool and belongs in a worker thread; a fixed worker pool starves under a burst of heavy tasks, so bound the queue with timeouts and keep fast work off it; and event-loop delay — not CPU — is the metric that matches the timeouts users feel. Diagnose from the signal, fix the blocking span, then re-measure.