Node.js: Code Outside the Browser / Node.js Internals and How it Works

The Event Loop in Practice

🎯 Goal of This Note

Write actual code to observe the event loop's behavior firsthand β€” testing timers, setImmediate, process.nextTick, file I/O, and the thread pool β€” to see if the theory from the previous lecture holds up in practice.


πŸ› οΈ Setup

  1. πŸ“₯ Grab the starter files for this section and open the project folder.

    Download the Repo & Open starter Folder : 2-how-node-works
    https://github.com/codevedadev/complete-node-bootcamp

  2. πŸ“„ Create a new file: event-loop.js

  3. πŸ“ There's also a starter file: test-file.txt (used for a file-read demo).

⚠️ Note: It's genuinely hard to fully simulate the event loop locally, since in a real production app, many callbacks would be queued up simultaneously (e.g., from thousands of incoming requests). Still, useful experiments can be done with a handful of async operations.


1️⃣ Writing the Initial Test Code

πŸ“ What Each Piece Represents

πŸ’» Code

πŸ“Œ Represents

const fs = require('fs')

πŸ“¦ Module loading β€” happens at startup, before the event loop starts

setTimeout(..., 0)

⏰ A timer β€” Phase 1 of the event loop

setImmediate(...)

⚑ Runs right after the I/O polling phase β€” Phase 3

fs.readFile(...)

πŸ“ An I/O task β€” Phase 2 (I/O polling/callbacks)

console.log('Hello from the top-level code')

πŸš€ Top-level code β€” runs immediately, before the event loop even starts


2️⃣ Running the Code β€” First Observations

βœ… Expected Behavior

  • Top-level code always runs first β€” since it's executed immediately when the module loads, before the event loop starts.

πŸ“‹ Actual Result

Hello from the top-level code
[the other three logs, in no fixed/predictable order]

πŸ€” Why the Other Three Logs Have No Fixed Order (Yet)

  • At this point, setTimeout, setImmediate, and fs.readFile are all called from top-level code β€” meaning they're not yet running inside a callback function that's itself inside the event loop.

  • Since none of these calls are nested inside another callback, their relative order isn't governed by event loop phase rules yet β€” it can vary between runs.

  • The file-read log (I/O finished) is usually last because reading files takes measurably longer than a 0second timer or an immediate callback.

πŸ’‘ To actually observe event loop ordering rules, these callbacks need to be inside callback functions β€” i.e., nested deeper, simulating a more realistic async chain.


3️⃣ Adding More Timers to Test Program Exit Behavior

❓ Why the Program Didn't Exit Immediately

  • Node checks whether there are any pending timers or I/O tasks before deciding to exit.

  • In the very first example (before adding Timer 3), once all callbacks finished, there was nothing left pending β€” so the program exited right away.

  • Now, with a 3-second timer added, Node keeps the event loop running (and the process alive) until that timer fires β€” only then does the program finally exit.


4️⃣ Understanding Why setImmediate Ran Before the Timers

😲 The Surprising Result

Even though setTimeout(..., 0) might seem like it should run at the same "priority level" as setImmediate, in practice:

🧠 Why Does This Happen?

  • We skipped this detail earlier to keep the event loop explanation simple.

  • When the poll phase has no I/O tasks to process, the event loop waits there for new work.

  • If a setImmediate() callback is already waiting, it runs immediately after the poll phase.

  • Because of this, setImmediate() can sometimes run before an expired setTimeout() callback.

πŸ“Œ Takeaway: Although it may seem surprising, this is the normal behavior of the Node.js event loop in certain situations.


5️⃣ Adding process.nextTick()

βœ… The Result

Process.nextTick was the very first callback to run β€” even before the timers and setImmediate calls.

πŸ€” Why This Happens

  • process.nextTick() belongs to the microtasks queue, which runs after every single phase of the event loop β€” not just once per full loop cycle ("tick").

  • So its callback gets squeezed in immediately after the current phase completes, before the event loop even moves to the next phase.

πŸ”€ The Confusing Naming Problem

  • nextTick sounds like it should run at the start of the next full loop cycle.

  • setImmediate sounds like it should run immediately.

  • πŸ”„ In reality:

    • πŸš€ process.nextTick() β†’ runs almost immediately.

    • ⚑ setImmediate() β†’ runs once per full loop cycle (after the poll phase).

πŸ’‘ Practical Advice

  • It's best to pick one and stick with it consistently in your code.

  • We generally recommends setImmediate over process.nextTick().

  • Both are considered advanced tools.


6️⃣ Demonstrating the Thread Pool with Cryptography

πŸ” The New Tool: crypto Module

πŸ“– Breaking Down pbkdf2()

πŸ“Œ Argument

πŸ“ Meaning

'password'

πŸ”‘ The secret string being encrypted

'salt'

πŸ§‚ Adds randomness/security

100000

πŸ”„ Number of iterations

1024

πŸ“ Key length

'sha512'

πŸ›‘οΈ Hashing algorithm

Callback

βœ… Runs once encryption finishes

🎯 The focus is on timing and thread pool behavior, not cryptography itself.

⏱️ Measuring Time Taken

  • Date.now() gives the current time in milliseconds.

  • Subtracting the start time shows how long the operation took.

πŸ§ͺ First Test: One Encryption

  • Result: ~1855 milliseconds (~1.8 seconds).

πŸ§ͺ Second Test: Four Simultaneous Encryptions

  • Result: All four encryptions finished in approximately the same amount of time.

πŸ€” Why?

  • 🧡 The default thread pool size is 4.

  • Each encryption gets its own thread and runs in parallel.


7️⃣ Changing the Thread Pool Size

UV_THREADPOOL_SIZE only matters for operations that actually use the thread pool β€” mainly fs (file operations), crypto (hashing/encryption), dns.lookup, and zlib (compression). It has no effect on setTimeout, setImmediate, or fs.readFile-style I/O that goes through the OS's own async mechanisms on some platforms β€” but for the classic thread-pool-bound example (like crypto.pbkdf2), here's what changes:

Pool Size

Effect on 4 Parallel Tasks

Default (4)

All 4 run simultaneously β€” finish in ~same time as 1

2

Only 2 run at once β€” the other 2 wait for a free thread, so total time roughly doubles

1

Fully sequential β€” one at a time, total time β‰ˆ 4Γ— a single task

8

Room for more parallel tasks β€” but only helps if you actually have more than 4 running at once

βš™οΈ UV_THREADPOOL_SIZE controls the number of available worker threads.

process.env.UV_THREADPOOL_SIZE = 1/2/3/4

The above code is asynchronous


8️⃣ Sync vs. Async Crypto β€” Blocking the Event Loop

  • Async (pbkdf2) β€” uses a callback, runs in the background, doesn't block anything.

  • Sync (pbkdf2Sync) β€” no callback, runs directly on the main thread, blocks everything until done.

❌ The Sync Version (Blocking)

  • No callback.

  • Blocks execution until finished.

  • Let's replace the asynchronous pbkdf2() with the synchronous pbkdf2Sync()

😲 The Dramatic Result

  • All four synchronous encryptions ran one after another.

  • ⏰ Timers, setImmediate, and process.nextTick() were all delayed until the synchronous work finished

🧠 Why This Happened

  • pbkdf2Sync() runs directly on the main thread.

  • The event loop couldn't process any callbacks until the blocking code completed.

  • Even UV_THREADPOOL_SIZE doesn’t work here since its sync code.

⚠️ This clearly demonstrates why blocking the event loop is dangerous.


πŸ”‘ Key Takeaways

πŸ“Œ Concept

βœ… Observed Behavior

Top-level code

Always runs first

setImmediate vs setTimeout(0)

setImmediate often wins, when nested inside I/O

process.nextTick()

Runs almost immediately β€” highest priority

Naming confusion

nextTick and setImmediate behave opposite to what their names imply

Thread pool default

4 threads

UV_THREADPOOL_SIZE

Controls pool size

Async functions

Offloaded to thread pool β€” non-blocking

Sync functions

Run on main thread β€” block everything

Real-world danger

Blocking delays every other pending callback

➑️ Big picture: phase ordering can be subtle, but the core lesson is simple β€” sync code blocks everything; async code keeps Node responsive.


Full Code In The event-loop.js File:

const fs = require("fs");
const crypto = require("crypto");
const start = Date.now();
process.env.UV_THREADPOOL_SIZE = 4;

setTimeout(() => console.log("Timer 1 finished"), 0);
setImmediate(() => console.log("Immediate 1 finished"));

fs.readFile("test-file.txt", () => {
  console.log("I/O finished");
  console.log("----------------");

  setTimeout(() => console.log("Timer 2 finished"), 0);
  setTimeout(() => console.log("Timer 3 finished"), 3000);
  setImmediate(() => console.log("Immediate 2 finished"));
  process.nextTick(() => console.log("Process.nextTick"));

  crypto.pbkdf2("password", "salt", 100000, 1024, "sha512", () => {
    console.log(Date.now() - start, "Password encrypted");
  });
  crypto.pbkdf2("password", "salt", 100000, 1024, "sha512", () => {
    console.log(Date.now() - start, "Password encrypted");
  });
  crypto.pbkdf2("password", "salt", 100000, 1024, "sha512", () => {
    console.log(Date.now() - start, "Password encrypted");
  });
  crypto.pbkdf2("password", "salt", 100000, 1024, "sha512", () => {
    console.log(Date.now() - start, "Password encrypted");
  });
});

console.log("Hello from the top-level code");
const fs = require('fs');
const crypto = require('crypto');

const start = Date.now();
process.env.UV_THREADPOOL_SIZE = 4;

setTimeout(() => console.log('Timerout 1 finished'), 0);
setImmediate(() => console.log('Immediate 1 finished'));

fs.readFile('test-file.txt', () => { 
  console.log('I/O finished');
  console.log('------------------');

setTimeout(() => console.log('Timerout 2 finished'), 0);
setTimeout(() => console.log('Timerout 3 finished'), 3000);
setImmediate(() => console.log('Immediate 2 finished'));

process.nextTick(() => console.log('Process.nextTick'));

crypto.pbkdf2Sync('password', 'salt', 100000, 1024, 'sha512');
console.log(Date.now() - start, 'Password encrypted');

crypto.pbkdf2Sync('password', 'salt', 100000, 1024, 'sha512');
console.log(Date.now() - start, 'Password encrypted');

crypto.pbkdf2Sync('password', 'salt', 100000, 1024, 'sha512');
console.log(Date.now() - start, 'Password encrypted');

crypto.pbkdf2Sync('password', 'salt', 100000, 1024, 'sha512');
console.log(Date.now() - start, 'Password encrypted');

});

console.log('Hello from the top-level code');