Blocking vs Non-Blocking
The Golden Rule of Node.js
LinkedIn Hook
"Your Node.js server handles 10,000 requests per second. Then one user uploads a 200 MB JSON file. Suddenly every other user is waiting. Why?"
Because Node.js runs your JavaScript on a single thread. When that thread is busy parsing a giant string, hashing a password synchronously, or running a nested loop, nothing else happens. Not the timers. Not the incoming HTTP requests. Not the database callbacks. Everything freezes.
This is the single most important mental model in Node.js: the event loop is a shared resource, and every millisecond you spend blocking it is a millisecond stolen from every other user on your server.
Most developers learn this the hard way — in production, during a traffic spike, while watching p99 latency climb from 50ms to 8 seconds. The fix is rarely "add more servers." The fix is almost always "stop blocking the event loop."
In Lesson 1.5, I break down exactly what blocking means, how to spot it in your code, how to measure it with
perf_hooks, and how to offload CPU work to worker threads — the stuff interviewers at Netflix, Stripe, and Vercel expect you to know cold.Read the full lesson -> [link]
#NodeJS #EventLoop #Performance #BackendEngineering #InterviewPrep
What You'll Learn
- The difference between synchronous and asynchronous fs operations (
readFileSyncvsreadFilevsfs.promises) - Why blocking the event loop destroys throughput — every blocked millisecond stalls every pending request
- How to identify blocking code: long loops, synchronous crypto,
JSON.parseon huge strings, catastrophic regex backtracking - How to measure event loop lag with
perf_hooks.monitorEventLoopDelayand theblocked-atpackage - How to offload CPU-heavy work using worker threads and child processes
- How
setImmediatelets you yield back to the event loop between chunks of work
The Single Cashier Analogy — One Thread, One Queue
Imagine a tiny grocery store with exactly one cashier. That cashier is incredibly fast — they can scan items, take payment, and hand back change in seconds. Customers love the speed. The line moves.
Now a customer arrives with a shopping cart full of 800 items, each one without a price tag. The cashier has to manually look up every single price in a thick paper catalog. For the next ten minutes, the cashier is completely unavailable. Every other customer — the person buying a single bottle of water, the one with a quick snack, the one who just needs change — all of them stand frozen in line.
The cashier is not slow. The cashier is blocked.
That cashier is the Node.js event loop. The line is your queue of pending requests, timers, and I/O callbacks. The customer with 800 items is your JSON.parse(hugeString) call or your synchronous bcrypt hash or your unbounded for loop. As long as that one operation is running, nothing else on your entire server can make progress — not even a health check endpoint that just returns { ok: true }.
The fix is not to hire more cashiers (that would be PHP or Java with thread-per-request). The fix is to never let one customer monopolize the cashier. Either break the work into small chunks and yield between them, or hand the work off to a back office (worker thread) so the cashier stays free.
+---------------------------------------------------------------+
| BLOCKING vs NON-BLOCKING TIMELINE |
+---------------------------------------------------------------+
| |
| BLOCKING (readFileSync, busy loop, sync crypto): |
| |
| time --> 0ms 500ms 1000ms 1500ms |
| | | | | |
| req A: [=== reading file ===] |
| req B: [WAITING.....WAITING.......] [run] |
| req C: [WAITING...WAITING....] [run] |
| req D: [WAITING.WAIT....] [run] |
| |
| One slow request freezes the entire server. |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| NON-BLOCKING (fs.promises, async I/O): |
| |
| time --> 0ms 500ms 1000ms 1500ms |
| | | | | |
| req A: [start]................[callback fires -> done] |
| req B: [start]..........[callback fires -> done] |
| req C: [start].....[callback fires -> done] |
| req D: [start][callback fires -> done] |
| |
| The event loop handles thousands of requests concurrently. |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). Split comparison: LEFT side labeled 'Blocking' shows a single Node.js event loop circle frozen with a red #ef4444 lock icon, while a tall stack of pending requests piles up behind it. RIGHT side labeled 'Non-Blocking' shows the same event loop spinning rapidly in Node green (#68a063) with requests flowing through smoothly and I/O operations handed off to background workers. Amber (#ffb020) arrows show the direction of flow. White monospace labels throughout."
Sync vs Async fs — Three Flavors of the Same API
Node's fs module ships three versions of almost every operation: the blocking synchronous version, the callback-based async version, and the promise-based async version. They all read the same bytes from the same disk, but they have wildly different consequences for your server.
readFileSync — The Blocking Version
// server-blocking.js
// The WRONG way to serve a file in an HTTP server
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
// readFileSync blocks the event loop until the file is fully read.
// While this line runs, NO other request can be processed.
// No timers fire. No DB callbacks run. Nothing.
const data = fs.readFileSync('./big-report.json');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(data);
});
server.listen(3000, () => {
console.log('Server listening on :3000 (blocking mode)');
});
If big-report.json is 50 MB, this call might block the event loop for 200-500 ms. During that window, every other request to your server is stalled. If 100 users hit this endpoint at once, the last user waits roughly 100 * 300 ms = 30 seconds, even though their request itself is trivial.
readFile — The Callback Version
// server-async-callback.js
// The classic non-blocking approach using a callback
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
// readFile hands the work to libuv's thread pool.
// The event loop is free to handle other requests immediately.
// When the file is ready, the callback is queued on the event loop.
fs.readFile('./big-report.json', (err, data) => {
if (err) {
res.writeHead(500);
return res.end('Internal Error');
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(data);
});
});
server.listen(3000);
The difference is enormous. The readFile call returns immediately. libuv handles the actual disk I/O on a background thread. The event loop moves on and starts processing the next request, then the next, then the next. When the file is ready, the callback is scheduled and runs briefly to send the response.
fs.promises — The Modern Version
// server-async-promises.js
// The same non-blocking behavior with cleaner syntax
const http = require('http');
const fs = require('fs/promises');
const server = http.createServer(async (req, res) => {
try {
// fs/promises wraps the same libuv calls in a Promise interface.
// The await yields control back to the event loop until ready.
const data = await fs.readFile('./big-report.json');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(data);
} catch (err) {
res.writeHead(500);
res.end('Internal Error');
}
});
server.listen(3000);
fs.promises (also available as fs/promises) is the recommended API for new code. It has identical performance characteristics to the callback version — both use libuv's thread pool under the hood — but the promise-based form composes cleanly with async/await, Promise.all, and try/catch.
Rule of thumb: readFileSync is acceptable only at startup (reading a config file before the server starts listening). Inside any request handler, always use the async form.
Why Blocking the Event Loop Ruins Throughput
Node.js is built on the premise that a single thread can serve thousands of concurrent connections because most real work is I/O, not CPU. The event loop spends almost all its time waiting for disks, networks, and databases. When a request arrives, the event loop does a tiny amount of JavaScript work, kicks off the I/O, and moves on. That is where the throughput comes from.
Break that promise — do CPU-heavy work on the main thread — and the model collapses.
+---------------------------------------------------------------+
| THE COST OF BLOCKING (100ms blocked) |
+---------------------------------------------------------------+
| |
| Server under load: 1000 req/sec |
| Average handler time: 5ms |
| |
| One request blocks for 100ms: |
| |
| - 100 other requests arrived during that window |
| - All 100 had their latency increased by 100ms |
| - p99 latency jumps from ~10ms to >100ms |
| - Timers scheduled to fire during the block are LATE |
| - Incoming TCP connections sit in the OS backlog |
| |
| Block for 1 second -> 1000 stalled requests. |
| Block for 10 seconds -> load balancer marks you DEAD. |
| |
+---------------------------------------------------------------+
Common Sources of Accidental Blocking
1. Long synchronous loops. A for loop that walks a million-element array and does non-trivial work per item can easily block for hundreds of milliseconds.
2. Synchronous crypto. crypto.pbkdf2Sync, bcrypt.hashSync, crypto.scryptSync — the Sync suffix is a warning sign. Use the async versions so libuv runs them on the thread pool.
3. JSON.parse and JSON.stringify on huge strings. There is no async version of these built-ins. Parsing a 100 MB JSON string can block for seconds.
4. Regular expressions with catastrophic backtracking. A badly written regex like /^(a+)+$/ on a crafted input can take exponential time. This is the basis of ReDoS (Regular Expression Denial of Service) attacks.
5. Synchronous zlib calls. zlib.gzipSync and friends block. Use zlib.gzip (callback) or the stream API.
Measuring Event Loop Lag with perf_hooks
You cannot fix what you cannot measure. Node ships a built-in API for measuring how delayed the event loop is: perf_hooks.monitorEventLoopDelay. It uses a high-resolution histogram under the hood.
// monitor-lag.js
// Track how much the event loop is lagging behind schedule
const { monitorEventLoopDelay } = require('perf_hooks');
// resolution: the histogram sampling interval in milliseconds.
// Lower values are more accurate but cost slightly more CPU.
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
// Report stats every 5 seconds
setInterval(() => {
// All values are in nanoseconds -> divide by 1e6 for milliseconds
const stats = {
min: (histogram.min / 1e6).toFixed(2),
mean: (histogram.mean / 1e6).toFixed(2),
max: (histogram.max / 1e6).toFixed(2),
p50: (histogram.percentile(50) / 1e6).toFixed(2),
p99: (histogram.percentile(99) / 1e6).toFixed(2),
};
console.log('Event loop delay (ms):', stats);
// Reset the histogram so each window is independent
histogram.reset();
}, 5000);
// Simulate some blocking work to see the lag spike
setTimeout(() => {
console.log('Starting a 500ms blocking operation...');
const end = Date.now() + 500;
while (Date.now() < end) {
// Busy wait -> blocks the event loop
}
console.log('Block done. Watch the next lag report.');
}, 2000);
In a healthy server, mean should be a few milliseconds at most and p99 should stay well under 50 ms. If you see p99 climbing into the hundreds of milliseconds, something is blocking the event loop and you need to find it.
For pinpointing which line of code caused the block, the blocked-at package (npm install blocked-at) patches setTimeout and records the stack trace of whatever ran just before a long delay was observed. It has overhead, so use it in staging, not production.
Offloading CPU Work — Worker Threads and Child Processes
Some work is genuinely CPU-bound and cannot be made asynchronous by calling libuv. Image resizing, password hashing, cryptographic signing, heavy JSON transformations, PDF generation — these need real CPU cycles. The answer is to move them off the main thread entirely.
Worker Threads (for CPU work in the same process)
// worker-example.js (main thread)
// Offload a CPU-heavy Fibonacci calculation to a worker
const { Worker } = require('worker_threads');
function runFibInWorker(n) {
return new Promise((resolve, reject) => {
// Spawn a new worker thread. It runs fib-worker.js in parallel.
const worker = new Worker('./fib-worker.js', { workerData: n });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error('Worker stopped with code ' + code));
});
});
}
// The main thread stays responsive while the worker computes
(async () => {
console.log('Main thread is still free...');
const result = await runFibInWorker(42);
console.log('Result from worker:', result);
})();
// fib-worker.js (runs on a separate OS thread)
const { parentPort, workerData } = require('worker_threads');
// Classic slow recursive Fibonacci -- intentionally CPU heavy
function fib(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
// Compute and ship the result back to the main thread
const result = fib(workerData);
parentPort.postMessage(result);
Worker threads share memory through SharedArrayBuffer and communicate via message passing. They are ideal for pure CPU work that needs to stay inside the same Node process.
Child Processes (for isolated tasks or calling other binaries)
child_process.spawn / fork run a completely separate Node process. Use them when you want full isolation (a crash in the child does not bring down the parent), or when you need to invoke an external binary like ffmpeg or ImageMagick.
Yielding with setImmediate — Breaking Up Big Loops
Sometimes you cannot move work to a worker — maybe it touches request-scoped state, or the setup cost is too high. In those cases, you can break the work into chunks and use setImmediate to yield control back to the event loop between chunks.
// chunked-processing.js
// Process a huge array without blocking the event loop
function processLargeArrayChunked(items, chunkSize, processItem) {
return new Promise((resolve) => {
let index = 0;
const results = [];
function processChunk() {
// Process up to chunkSize items synchronously
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
results.push(processItem(items[index]));
}
if (index < items.length) {
// Yield back to the event loop. Other pending I/O
// callbacks and timers get a chance to run before
// we continue with the next chunk.
setImmediate(processChunk);
} else {
resolve(results);
}
}
processChunk();
});
}
// Example usage inside an HTTP handler
const http = require('http');
http.createServer(async (req, res) => {
const huge = new Array(1_000_000).fill(0).map((_, i) => i);
// Without chunking this would block for hundreds of milliseconds.
// With chunking the server stays responsive to other requests.
const doubled = await processLargeArrayChunked(huge, 10_000, (x) => x * 2);
res.end('Processed ' + doubled.length + ' items');
}).listen(3000);
setImmediate(fn) schedules fn to run on the next iteration of the event loop, after pending I/O callbacks have had a chance to run. This makes it the correct primitive for "yield, then keep working." process.nextTick is too eager — it runs before I/O — and setTimeout(fn, 0) has a minimum delay of 1 ms, so it is slower than setImmediate for this pattern.
Common Mistakes
1. Using readFileSync, existsSync, or writeFileSync inside request handlers.
The Sync suffix is a red flag in any code path that serves traffic. These calls block the event loop for the duration of the disk I/O. They are only acceptable at startup, before server.listen() is called. Inside handlers, always use the async fs or fs.promises equivalents.
2. Treating async as "free concurrency" when the inner work is CPU-bound.
Wrapping a synchronous bcrypt.hashSync call in an async function does not make it non-blocking. The async keyword only matters when there is an await on a genuinely asynchronous operation. If your async function runs pure CPU code from start to finish, it blocks the event loop just as badly as a sync function. Use the async crypto APIs (bcrypt.hash, crypto.scrypt) which run on libuv's thread pool.
3. Parsing untrusted JSON without a size limit.
JSON.parse is synchronous and has no built-in limit. An attacker sending a 500 MB JSON body can freeze your server for seconds per request. Always enforce a body size limit (express.json({ limit: '1mb' }), fastify default limits) and reject oversized payloads before they reach JSON.parse.
4. Writing regexes with nested quantifiers on user input.
Patterns like /^(a+)+$/, /(.*a){25}/, or /^(\w+\s?)*$/ are vulnerable to catastrophic backtracking. On a malicious input, they can take minutes to match a few hundred characters. Use linear-time regex engines (the re2 package wraps Google's RE2), validate input shape before regex, or rewrite the pattern to eliminate ambiguity.
5. Ignoring event loop lag metrics in production.
Most dashboards track CPU, memory, and request latency — but miss event loop delay. A Node process can look "fine" on CPU (20% usage) while its event loop lags by 500 ms because one background task is hogging the main thread. Always export monitorEventLoopDelay percentiles to your metrics system (Prometheus, Datadog, etc.) and alert on p99 > 100 ms.
Interview Questions
1. "What does it mean to 'block the event loop' in Node.js, and why is it bad?"
The Node.js event loop is a single thread that runs all of your JavaScript code, including every HTTP handler, timer callback, and I/O completion callback. "Blocking the event loop" means running synchronous code — a long loop, a sync crypto call, a large JSON.parse — that keeps the thread busy for more than a few milliseconds. Because there is only one thread, nothing else can run during that time: no new requests are accepted, no timers fire, no database callbacks execute. On a server handling 1000 req/sec, blocking for 100 ms stalls roughly 100 other requests. It also breaks SLAs and can cause load balancers to mark the instance unhealthy. The cardinal rule is: never do CPU-heavy work on the main thread — use fs.promises for I/O, async crypto APIs, worker threads for raw CPU work, and setImmediate to yield between chunks of long computations.
2. "What is the difference between fs.readFileSync, fs.readFile, and fs.promises.readFile? When would you use each?"
All three read the same file from disk, but they differ in how they deliver the result. readFileSync is fully synchronous — it blocks the event loop until the file is read and returns the buffer directly. Use it only at application startup (reading a config file before server.listen). readFile is asynchronous with a Node-style callback: it hands the I/O to libuv's thread pool, returns immediately, and invokes your callback when the data is ready. fs.promises.readFile is the same async operation wrapped in a Promise, which composes cleanly with async/await. Both async versions have identical performance — use fs.promises for new code because the syntax is cleaner and error handling with try/catch is more natural. Inside any HTTP handler, never use readFileSync.
3. "How would you detect that a Node.js server is being slowed down by event loop blocking?"
The most reliable tool is perf_hooks.monitorEventLoopDelay, which builds a histogram of how late the event loop is running compared to its ideal schedule. You enable it once at startup, then periodically read percentiles like mean and p99 (values are in nanoseconds). A healthy server has a mean under 5 ms and p99 under 50 ms. If you see p99 climbing into the hundreds of milliseconds, something is blocking the loop. To pinpoint the culprit, you can use the blocked-at package, which captures stack traces whenever a block exceeds a threshold — useful in staging because it has overhead. In production, you want the perf_hooks histogram exported to your metrics system so you can alert on event loop delay the same way you alert on CPU or memory.
4. "When should you use worker threads versus setImmediate chunking?"
Use setImmediate chunking when the work is splittable into small pieces and runs for tens to hundreds of milliseconds total — for example, transforming a large array where you can process 10,000 items, yield, then continue. This keeps the work on the main thread but lets other I/O callbacks interleave. Use worker threads when the work is genuinely CPU-heavy for a significant duration (hundreds of milliseconds or more), when it runs a tight inner loop that cannot be naturally chunked (image processing, cryptographic hashing, compression), or when you want to parallelize across CPU cores. Worker threads have startup cost and require message passing (or SharedArrayBuffer), so they are not worth it for tiny tasks, but they are the only way to actually use multiple cores within a single Node process.
5. "Why is JSON.parse on a large request body a security concern, and how do you defend against it?"
JSON.parse is synchronous and has no built-in size limit or streaming mode — it parses whatever string you hand it, all at once, on the main thread. An attacker who can send arbitrary request bodies can submit a payload of 100 MB or more, forcing your server to spend seconds blocked in JSON.parse. During that time your entire server is frozen — every other user waits. This is a denial-of-service vector. The defenses are: (1) enforce a strict body size limit at the HTTP framework level (express.json({ limit: '100kb' }) or the Fastify default) so oversized bodies are rejected before parsing; (2) validate Content-Length headers; (3) for legitimately large JSON payloads, use a streaming parser like stream-json that processes the document incrementally without blocking. The same principle applies to JSON.stringify in the response path — if you might serialize a huge object, stream it or paginate.
Quick Reference — Blocking vs Non-Blocking Cheat Sheet
+---------------------------------------------------------------+
| SYNC vs ASYNC fs CHEAT SHEET |
+---------------------------------------------------------------+
| |
| STARTUP ONLY (blocking is ok): |
| const cfg = fs.readFileSync('./config.json', 'utf8') |
| |
| INSIDE HANDLERS (callback): |
| fs.readFile(path, (err, data) => { ... }) |
| |
| INSIDE HANDLERS (promise, preferred): |
| const fs = require('fs/promises') |
| const data = await fs.readFile(path) |
| |
| CPU-HEAVY WORK: |
| const { Worker } = require('worker_threads') |
| new Worker('./task.js', { workerData: input }) |
| |
| LONG LOOPS (yield every N items): |
| setImmediate(() => processNextChunk()) |
| |
| MEASURE LAG: |
| const h = monitorEventLoopDelay(); h.enable() |
| h.percentile(99) / 1e6 // ms |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| RED FLAGS (likely blocking the event loop) |
+---------------------------------------------------------------+
| |
| 1. Any function name ending in Sync (inside handlers) |
| 2. bcrypt.hashSync / crypto.pbkdf2Sync / scryptSync |
| 3. JSON.parse on untrusted or large payloads |
| 4. Regex with nested quantifiers on user input |
| 5. zlib.gzipSync / deflateSync |
| 6. Long for/while loops over big arrays |
| 7. Recursive algorithms without a yield point |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| KEY RULES |
+---------------------------------------------------------------+
| |
| 1. Never use *Sync fs calls inside request handlers |
| 2. Use fs/promises for new code |
| 3. Measure event loop lag with perf_hooks in production |
| 4. Alert on p99 event loop delay > 100ms |
| 5. Offload CPU work to worker threads |
| 6. Chunk long loops with setImmediate to yield |
| 7. Enforce request body size limits before JSON.parse |
| 8. Prefer async crypto APIs over their Sync counterparts |
| |
+---------------------------------------------------------------+
| Operation | Blocking? | Where it runs | Safe in handlers? |
|---|---|---|---|
fs.readFileSync | Yes | Main thread | No |
fs.readFile (callback) | No | libuv thread pool | Yes |
fs.promises.readFile | No | libuv thread pool | Yes |
crypto.pbkdf2Sync | Yes | Main thread | No |
crypto.pbkdf2 | No | libuv thread pool | Yes |
JSON.parse (large input) | Yes | Main thread | Only with size limit |
zlib.gzipSync | Yes | Main thread | No |
zlib.gzip | No | libuv thread pool | Yes |
| Worker thread task | No (for main) | Separate OS thread | Yes |
setImmediate(fn) | No | Next loop tick | Yes |
Prev: Lesson 1.4 -- V8 Engine and Memory Next: Lesson 2.1 -- CommonJS
This is Lesson 1.5 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.