Every Node.js tutorial mentions the event loop. Most explanations include a diagram with boxes and arrows, say "it's single-threaded but non-blocking," and move on. That's enough to get started. It isn't enough to debug a production API that's dropping requests under load.
I spent years writing Node.js without truly understanding the event loop. When things worked, they worked. When they didn't, I threw more instances at the problem. Eventually I hit a bug that couldn't be solved with more instances, and I had to actually learn what was happening.
The call stack
JavaScript has one call stack. When you call a function, it goes on the stack. When the function returns, it comes off. If a function calls another function, they stack up. If a function takes a long time, everything waits.
This is why a CPU-intensive operation blocks the entire process. If you write a function that computes prime numbers for 5 seconds, no other JavaScript can execute during those 5 seconds. No request handlers, no timers, nothing.
// This blocks the entire process for ~5 seconds
function computePrimes(limit) {
const primes = [];
for (let i = 2; i < limit; i++) {
let isPrime = true;
for (let j = 2; j <= Math.sqrt(i); j++) {
if (i % j === 0) { isPrime = false; break; }
}
if (isPrime) primes.push(i);
}
return primes;
}
app.get('/primes', (req, res) => {
const result = computePrimes(10_000_000);
res.json({ count: result.length });
});
While this handler runs, every other request to the server queues up. The event loop can't process them because the call stack is occupied.
Task queue vs microtask queue
When an asynchronous operation completes, its callback doesn't go directly onto the call stack. It goes into a queue. But there are two queues, and they have different priorities.
The microtask queue handles Promise callbacks (.then, .catch, async/await continuations) and process.nextTick. The task queue (also called the macrotask queue) handles setTimeout, setInterval, I/O callbacks, and setImmediate.
The rule: after every call stack frame clears, the event loop drains the entire microtask queue before processing the next task from the task queue.
console.log('1 - start');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - promise'));
process.nextTick(() => console.log('4 - nextTick'));
console.log('5 - end');
Output:
1 - start
5 - end
4 - nextTick
3 - promise
2 - setTimeout
process.nextTick runs before promises. Both run before setTimeout. This ordering matters when you're coordinating async operations.
The dangerous implication: if you recursively schedule microtasks, the task queue starves. The event loop never gets to process I/O callbacks because the microtask queue never empties.
// This starves the event loop
function recursive() {
Promise.resolve().then(recursive);
}
recursive();
// setTimeout callbacks will never fire
The libuv thread pool
Node.js is "single-threaded" for JavaScript execution. But it uses a thread pool (managed by libuv) for certain operations that would otherwise block the process.
The default thread pool size is 4. These threads handle:
- File system operations (
fs.readFile,fs.writeFile, etc.) - DNS lookups (
dns.lookup, which is used byhttp.getandhttps.get) - Some crypto operations (
crypto.pbkdf2,crypto.randomBytes) - Compression (
zlib)
Network I/O (TCP, HTTP) doesn't use the thread pool. It uses the OS kernel's event notification system (epoll on Linux, kqueue on macOS).
This means that if you make 100 concurrent fs.readFile calls, only 4 run at a time. The rest queue up waiting for a thread. If those reads are slow (large files, slow disk), it creates a bottleneck that looks like the event loop is blocked but is actually a thread pool exhaustion.
You can increase the pool size:
UV_THREADPOOL_SIZE=16 node server.js
The maximum is 1024. Setting it too high wastes memory. Setting it too low creates bottlenecks. For most API servers, 8 to 16 is reasonable.
Promise.all is not parallel
This is the misconception I see most often. Promise.all doesn't make things run in parallel. It waits for multiple promises that are already running.
// These three fetches start at the same time because
// fetch() returns immediately with a pending promise.
// Promise.all just waits for all of them.
const [users, posts, comments] = await Promise.all([
fetch('/api/users'),
fetch('/api/posts'),
fetch('/api/comments'),
]);
The fetches are concurrent because fetch() is non-blocking. But if you do CPU-intensive work in each promise, it's still sequential:
const results = await Promise.all([
computePrimes(1_000_000), // Blocks the thread
computePrimes(1_000_000), // Waits for the first to finish
computePrimes(1_000_000), // Waits for the second to finish
]);
These run sequentially because they occupy the call stack. JavaScript can't execute two functions at the same time on the same thread. Promise.all doesn't change this.
For true parallelism with CPU work, you need worker_threads.
async/await and the event loop
async/await is syntactic sugar for promises. When you await something, the function pauses and the rest of it's scheduled as a microtask for when the awaited promise resolves.
async function handleRequest(req, res) {
const user = await getUser(req.userId); // Function pauses here
// Everything below is a microtask scheduled after getUser resolves
const orders = await getOrders(user.id);
res.json({ user, orders });
}
Between the two await calls, other code can run. The event loop is free to process other callbacks, handle other requests, run timers. This is the fundamental mechanism that lets Node.js handle thousands of concurrent connections with a single thread.
But if you accidentally await something that isn't truly async, you get sequential execution with overhead:
// Bad: sequential when it could be concurrent
const user = await getUser(id);
const posts = await getPosts(id);
const comments = await getComments(id);
// Better: concurrent
const [user, posts, comments] = await Promise.all([
getUser(id),
getPosts(id),
getComments(id),
]);
The first version takes the sum of all three response times. The second takes the maximum. For three 100ms API calls, that's 300ms vs 100ms.
The real-world bug
The API I was debugging had a request handler that processed uploaded CSV files. It read the file from disk, parsed it, validated each row against the database, and wrote the results. Under normal load it was fine. Under concurrent uploads it became unresponsive.
The issue was the CSV parsing. It was a synchronous loop over thousands of rows, each involving string splitting and regex validation. While one upload was being processed, the call stack was occupied and no other requests could be handled.
The fix was to break the parsing into chunks using setImmediate:
async function parseInChunks(rows, chunkSize = 100) {
const results = [];
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const processed = chunk.map(parseRow);
results.push(...processed);
// Yield to the event loop between chunks
await new Promise(resolve => setImmediate(resolve));
}
return results;
}
This processes 100 rows, yields control to the event loop so other requests can be handled, then continues. The total processing time increased slightly, but the server remained responsive during uploads.
Understanding the event loop turned this from a "we need more servers" problem into a 10-line fix.