⚙️ Node.js Architecture and the Event Loop
Last lesson you ran non-blocking JavaScript and took it on faith. Now we open the engine bay. Understanding the event loop — the tireless coordinator at Node's core — turns confusing output ordering into something you can predict, and turns "why is my server slow?" into a question you can actually answer.
Week 7 · Day 1 (Monday: Node.js Fundamentals) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe how V8, libuv, and Node's C++ core fit together
- Explain the event loop and name its six phases in order
- Distinguish microtasks (
process.nextTick, promises) from macrotasks (timers,setImmediate, I/O) - Predict the output order of mixed synchronous and asynchronous code
- Explain the libuv thread pool and which operations use it
- Recognise event-loop blocking and offload heavy work to Worker Threads
Estimated Time: 70 minutes
Practice: Predict then verify the execution order of a tricky script, and move a CPU-heavy task into a Worker Thread.
In This Lesson
The Big Picture
Node.js is not one program but a small team of collaborators. Your JavaScript sits on top; underneath, two heavyweights do the real work: V8 executes your code, and libuv handles asynchronous I/O and the event loop. A layer of C++ glue binds them to Node's built-in modules.
runs JavaScript"] CORE["Node Core (C++)
bindings"] LIBUV["libuv
event loop + thread pool"] JSLIB["JS Standard Library
fs, http, path..."] end OS["Operating System APIs"] APP --> V8 V8 --- CORE CORE --- LIBUV CORE --- JSLIB LIBUV --> OS
🧩 Who does what
- V8 — Google's engine; compiles and runs your JavaScript.
- libuv — C library providing the event loop, async file/network I/O, and a thread pool.
- Node Core (C++) — binds JavaScript calls to system operations.
- JS Standard Library — the
fs,http,path,eventsmodules you call.
🎻 The Orchestra Analogy
Think of it as an orchestra: your code is the score, V8 is the conductor reading it, libuv's sections are the musicians, and the event loop is the beat keeping everyone in time. The conductor doesn't play every instrument — it coordinates when each part comes in. That coordination is the event loop.
V8: The JavaScript Engine
V8 doesn't crawl through your code line by line. It compiles JavaScript into fast machine code, using a pipeline that trades a little startup time for big speed on code that runs often (a "hot path").
interpreter"] D --> E["Bytecode"] E --> F["TurboFan
optimizing compiler"] F --> G["Optimized
machine code"]
- Parser turns source text into an Abstract Syntax Tree (AST).
- Ignition generates bytecode from the AST and runs it.
- TurboFan watches for hot code and recompiles it into highly optimized machine code.
- Garbage collection reclaims memory you no longer reference — automatically.
✅ Write V8-friendly code
V8 optimizes best when object shapes stay consistent. Initialize all properties up front rather than tacking them on conditionally:
// 👎 Shape changes depending on inputs — harder to optimize
function makeUser(name, age) {
const user = {};
if (name) user.name = name;
if (age) user.age = age;
return user;
}
// 👍 Same shape every time — V8 loves this
function makeUser(name, age) {
return { name: name ?? '', age: age ?? 0 };
}
Don't obsess over micro-optimizations day to day — but predictable object shapes are a genuinely good habit.
The Event Loop & Its Phases
The event loop is a loop that runs as long as there is work to do. Each turn ("tick") it moves through a fixed sequence of phases, and in each phase it drains a specific queue of callbacks. Between every callback it also empties the microtask queue (more on that shortly).
The six phases
- Timers — runs callbacks scheduled by
setTimeout()andsetInterval()whose time has elapsed. - Pending callbacks — runs certain system-level I/O callbacks deferred from the previous tick.
- Idle / prepare — internal use only.
- Poll — retrieves new I/O events and runs their callbacks; the loop may wait here for work.
- Check — runs
setImmediate()callbacks. - Close — runs close callbacks like
socket.on('close', ...).
Order in action
console.log('1 — start');
setTimeout(() => console.log('2 — setTimeout 0'), 0);
setImmediate(() => console.log('3 — setImmediate'));
process.nextTick(() => console.log('4 — nextTick'));
Promise.resolve().then(() => console.log('5 — promise'));
console.log('6 — end');
Output
1 — start
6 — end
4 — nextTick // microtasks drain first...
5 — promise // ...nextTick before promises
2 — setTimeout 0 // then the timers phase
3 — setImmediate // then the check phase
Why this order? All synchronous code runs first (1 and 6). Before the loop advances to any phase, it empties the microtask queue — and process.nextTick callbacks jump ahead of promise callbacks (4 then 5). Only then do the phases run: the timer (2) and the check-phase setImmediate (3).
Microtasks vs Macrotasks
Every async callback is either a microtask or a macrotask. The distinction is the single most useful key to predicting execution order.
| Microtasks (run between everything) | Macrotasks (one per phase turn) |
|---|---|
process.nextTick (highest priority) | setTimeout / setInterval |
Promise .then / await | setImmediate |
queueMicrotask | I/O callbacks |
The rule the loop follows
- Run all synchronous code.
- Drain the entire microtask queue (
nextTickcallbacks first, then promises). - Run one macrotask from the current phase.
- Drain the microtask queue again.
- Repeat.
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
Promise.resolve().then(() => console.log('promise inside timeout'));
}, 0);
Promise.resolve().then(() => console.log('promise 1'));
Promise.resolve().then(() => console.log('promise 2'));
console.log('script end');
Output
script start
script end
promise 1
promise 2
setTimeout
promise inside timeout
⚠️ Don't starve the loop with nextTick
Because process.nextTick callbacks run before the loop can advance, an endless chain of them will block every timer and I/O callback forever. Reach for queueMicrotask or setImmediate unless you specifically need nextTick's priority.
The libuv Thread Pool
"Single-threaded" describes your JavaScript. Behind the scenes, libuv keeps a small pool of background threads (4 by default, configurable up to 1024) for operations the OS can't do asynchronously on its own.
(main thread)"] --> B["libuv Event Loop"] B --> C["OS async I/O
(network sockets)"] B --> D["Thread Pool
(file I/O, crypto, zlib, DNS)"] C --> B D --> B
Network I/O usually uses efficient OS mechanisms directly. But file system work, crypto hashing, zlib compression, and some DNS lookups are handed to the thread pool so they don't block the main thread.
const crypto = require('node:crypto');
const start = Date.now();
// Each pbkdf2 hash is CPU-heavy and runs on a pool thread.
for (let i = 0; i < 8; i++) {
crypto.pbkdf2('password', 'salt', 100000, 512, 'sha512', () => {
console.log(`Hash ${i + 1} done in ${Date.now() - start}ms`);
});
}
// With the default pool of 4, the first four finish together,
// then the next four finish together a bit later.
💡 Resizing the pool
Set the pool size before Node starts with the UV_THREADPOOL_SIZE environment variable:
# Linux / macOS
UV_THREADPOOL_SIZE=8 node app.js
# Windows (PowerShell)
$env:UV_THREADPOOL_SIZE=8; node app.js
Bigger isn't always better — too many threads add context-switching overhead. Match it to your workload and measure.
Blocking vs Non-Blocking
A blocking call stops the single thread until it finishes — meaning every other pending request waits too. A non-blocking call starts the work and returns immediately, letting the loop serve others.
| Blocking | Non-Blocking |
|---|---|
fs.readFileSync() | fs.readFile() / fs.promises.readFile() |
| Long synchronous loops / heavy math | Timers & event listeners |
Synchronous crypto (*Sync) | Callback- or promise-based I/O |
const fs = require('node:fs');
// ❌ Blocking — nothing else runs until the read completes
console.log('start');
const data = fs.readFileSync('big.txt', 'utf8');
console.log(`read ${data.length} chars`);
console.log('next');
// Output: start → read ... chars → next
// ✅ Non-blocking — 'next' logs while the disk works
console.log('start');
fs.readFile('big.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(`read ${data.length} chars`);
});
console.log('next');
// Output: start → next → read ... chars
🏎️ Real-world impact
Imagine a 10 ms blocking operation and 1,000 queued requests. Because they can't overlap, the last visitor waits a full 10 seconds. Make it non-blocking and the server keeps moving while the OS does the waiting. This is exactly why teams like PayPal saw big throughput gains after moving to Node.
Escaping with Worker Threads
Non-blocking I/O solves waiting, not computing. A genuinely CPU-heavy task (say, resizing thousands of images) will still hog the main thread. The escape hatch is the worker_threads module, which runs code on a separate OS thread with its own V8 instance.
// main.js
const { Worker } = require('node:worker_threads');
function runHeavyJob(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
});
});
}
(async () => {
const result = await runHeavyJob({ numbers: [1, 2, 3, 4, 5] });
console.log('Result:', result); // main thread stayed responsive throughout
})();
// worker.js
const { workerData, parentPort } = require('node:worker_threads');
function crunch(numbers) {
let total = 0;
for (let i = 0; i < 10_000_000; i++) {
total += numbers.reduce((sum, n) => sum + n * n, 0);
}
return total;
}
parentPort.postMessage(crunch(workerData.numbers));
✅ The mental model
Event loop = concurrency for waiting (I/O). Worker Threads = parallelism for thinking (CPU). Use the right one and the main thread never stalls.
Practice & Quiz
🏋️ Exercise 1: Predict, then verify
Goal: Before running the code below, write down the order you expect. Then run it with node order.js and compare.
// order.js
console.log('A');
setTimeout(() => console.log('B'), 0);
setImmediate(() => console.log('C'));
Promise.resolve().then(() => console.log('D'));
process.nextTick(() => console.log('E'));
console.log('F');
💡 Hint
Synchronous logs first (A, F). Then microtasks drain — nextTick (E) before the promise (D). Then the timer (B) and check phase (C).
✅ Solution
// Output:
// A
// F
// E ← nextTick (microtask, highest priority)
// D ← promise (microtask)
// B ← setTimeout (timers phase)
// C ← setImmediate (check phase)
Note: the relative order of a bare setTimeout(fn, 0) and setImmediate at the top level can vary by machine — but inside an I/O callback, setImmediate always wins.
🏋️ Exercise 2: Unblock the loop
Goal: The function below blocks the event loop for seconds. Move the heavy loop into a Worker Thread so the main thread stays free.
// blocker.js — this freezes everything while it runs
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(45)); // blocks the main thread
✅ Solution
// fib-worker.js
const { workerData, parentPort } = require('node:worker_threads');
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
parentPort.postMessage(fib(workerData));
// main.js
const { Worker } = require('node:worker_threads');
const w = new Worker('./fib-worker.js', { workerData: 45 });
w.on('message', (result) => console.log('fib(45) =', result));
console.log('Main thread is still responsive!'); // logs first
🎯 Quick Quiz
Question 1: Which runs first after synchronous code finishes?
Question 2: How many threads does the libuv thread pool have by default?
Question 3: What's the right fix for a long CPU-bound calculation?
Best Practices & Pitfalls
✅ Do
- Prefer asynchronous, non-blocking APIs everywhere in server code
- Offload CPU-heavy work to Worker Threads (or a separate service)
- Use
Promise.all()to run independent I/O in parallel - Keep object shapes stable so V8 can optimize
❌ Don't
- Call
*Syncmethods on a request's hot path - Recursively schedule
process.nextTick— it starves the loop - Assume a top-level
setTimeout(0)always beatssetImmediate— inside I/O it doesn't - Run tight, long-running loops on the main thread
💡 Parallel beats sequential for independent I/O
const fs = require('node:fs/promises');
// 👎 Sequential — each await waits for the previous
const a = await fs.readFile('a.txt', 'utf8');
const b = await fs.readFile('b.txt', 'utf8');
// 👍 Parallel — both reads run at once
const [x, y] = await Promise.all([
fs.readFile('a.txt', 'utf8'),
fs.readFile('b.txt', 'utf8'),
]);
Summary
🎉 Key Takeaways
- Node = V8 (runs JS) + libuv (event loop & thread pool) + C++ bindings
- The event loop cycles through six phases: timers → pending → poll → check → close (plus internal idle/prepare)
- Microtasks (
nextTick, promises) drain between everything; macrotasks run one per phase turn - The thread pool (4 by default) handles file I/O, crypto, and zlib off the main thread
- Non-blocking I/O gives concurrency; Worker Threads give CPU parallelism
📚 Additional Resources
- Node.js — The Event Loop, Timers, and process.nextTick()
- Node.js — Don't Block the Event Loop
- Node.js — Worker Threads API
🚀 What's Next?
Now that you understand how Node runs your code, it's time to use the tools it ships with. Next up: the core modules — fs, path, and http — the built-in building blocks of every Node application.
🎉 You've seen the engine!
Output ordering will never confuse you again. That intuition pays off in every async bug you'll ever debug.