Skip to Content
Course content

143: The Node.js Event Loop vs the Browser's

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you're comfortable with the general idea that JavaScript is single-threaded and uses an event loop to handle asynchronous tasks. But here is the secret: there is no single "JavaScript Event Loop." The event loop is an implementation detail of the environment. Chrome's V8 engine in the browser handles the loop differently than Node.js does on your server, even though they both execute the same language.

To really see this in action, I want us to build a small "Execution Order Logger." We'll write one piece of code and run it in both environments to see where they diverge. If you've ever wondered why your setTimeout(fn, 0) didn't fire exactly when you expected, this is where the answer lies.

Establishing a common baseline

Let's start with something that behaves almost identically in both the browser and Node. I'll mix a synchronous log, a macrotask (the timer), and a microtask (the promise).

console.log('🚀 Start');

setTimeout(() => {
  console.log('⏲️ Timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('📦 Promise');
});

console.log('🏁 End');

If you run this in your browser console or via node index.js, you'll get the same result: Start, End, Promise, Timeout. This is because both environments prioritize the Microtask Queue (Promises) over the Macrotask Queue (Timers) once the current call stack is clear. So far, so good.

Introducing the Node.js specifics

Now, let's add some tools that only exist in Node.js. I'm talking about setImmediate() and process.nextTick(). These are the parts that usually trip people up because they sound like they do the same thing as setTimeout or Promises, but they hook into different phases of the Node loop.

console.log('🚀 Start');

setTimeout(() => console.log('⏲️ Timeout'), 0);
setImmediate(() => console.log('⚡ Immediate'));
process.nextTick(() => console.log('⏭️ NextTick'));
Promise.resolve().then(() => console.log('📦 Promise'));

console.log('🏁 End');

When I first started with Node, I assumed process.nextTick was just another way to write a Promise. I thought it would just go into the microtask queue and they'd execute in the order they were called. I was wrong.

The mistake I made with the Microtask Queue

In my head, I expected the output to be NextTick then Promise, or vice versa. But when I ran this, I saw that process.nextTick always wins. It doesn't just join the microtask queue; it actually tells Node, "Finish whatever you're doing right now, and then execute this before you even look at the promise queue."

If you run the code above in Node, you'll see: Start, End, NextTick, Promise. The nextTick queue is processed immediately after the current operation, before the Promise microtask queue. It's like a "fast pass" at a theme park.

The battle between Timeout and Immediate

Now, look at the setTimeout and setImmediate. If you run this script in a standalone file, you might notice something frustrating: the order between "Timeout" and "Immediate" is actually non-deterministic. Sometimes Timeout wins; sometimes Immediate wins.

Why? Because setTimeout(0) depends on the system clock's precision. If the loop enters the "timers" phase and 1ms has passed, it fires. If not, it moves on to the "poll" phase, and setImmediate (which lives in the "check" phase) will fire first.

However, if we wrap these inside an I/O cycle—like reading a file—the behavior becomes predictable. I'll modify our logger to run inside a fs.readFile callback:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('⏲️ Timeout'), 0);
  setImmediate(() => console.log('⚡ Immediate'));
});

In this case, setImmediate will always fire first. This is because the readFile callback executes in the "poll" phase. The very next phase in the Node.js loop is the "check" phase, where setImmediate lives. The "timers" phase is way back at the start of the next loop iteration. This is a fundamental difference from the browser, which doesn't have these distinct phase-based buckets.




📋 Practical Task

Exercise: Predicting the Node.js Execution Race

You are debugging a Node.js application where a developer attempted to ensure a specific cleanup function runs as soon as possible, but before the next event loop tick. They've provided you with the following snippet. Your goal is to determine the exact order of the console logs and explain why process.nextTick overrides the Promise.

const fs = require('fs');

console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve().then(() => console.log('C'));

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

fs.readFile(__filename, () => {
    console.log('E');
    setImmediate(() => console.log('F'));
    setTimeout(() => console.log('G'), 0);
});

console.log('H');

Requirements:

  • Write out the final sequence of letters (A-H) as they will appear in the terminal.
  • Identify which letters are part of the "Microtask" phase and which are part of the "Timer" or "Check" phases.
  • Explain why 'D' appears before 'C' despite both being asynchronous callbacks triggered at the start of the script.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.