Skip to main content

⚙️ 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.

flowchart TB APP["Your JavaScript Application"] subgraph Runtime["Node.js Runtime"] V8["V8 Engine
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, events modules 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").

flowchart LR A["JavaScript"] --> B["Parser"] B --> C["AST"] C --> D["Ignition
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 of the Node.js event loop arranged in a cycle 1. Timers setTimeout / setInterval 2. Pending deferred I/O callbacks 3. Poll retrieve new I/O 4. Check setImmediate 5. Close socket 'close' events Idle / Prepare internal only
Each tick sweeps these phases in order, then loops. The idle/prepare phase is for Node's internal bookkeeping.

The six phases

  1. Timers — runs callbacks scheduled by setTimeout() and setInterval() whose time has elapsed.
  2. Pending callbacks — runs certain system-level I/O callbacks deferred from the previous tick.
  3. Idle / prepare — internal use only.
  4. Poll — retrieves new I/O events and runs their callbacks; the loop may wait here for work.
  5. Check — runs setImmediate() callbacks.
  6. 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 / awaitsetImmediate
queueMicrotaskI/O callbacks

The rule the loop follows

  1. Run all synchronous code.
  2. Drain the entire microtask queue (nextTick callbacks first, then promises).
  3. Run one macrotask from the current phase.
  4. Drain the microtask queue again.
  5. 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.

flowchart LR A["JavaScript
(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.

BlockingNon-Blocking
fs.readFileSync()fs.readFile() / fs.promises.readFile()
Long synchronous loops / heavy mathTimers & 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 *Sync methods on a request's hot path
  • Recursively schedule process.nextTick — it starves the loop
  • Assume a top-level setTimeout(0) always beats setImmediate — 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

🚀 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 modulesfs, 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.