Foreword
Let's start with a simple example:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
The log output order is as follows:
script start
script end
promise1
promise2
setTimeout
Why?
macrotask
Let's tentatively call these "macrotasks," often simply referred to as "tasks" in many contexts. Examples include:
-
setTimeout -
setInterval -
setImmediate -
requestAnimationFrame -
I/O
-
UI rendering
The most common delayed or interval calls, immediate calls in Node environments, high-frequency RAF, as well as I/O operations and UI updates. These are all macrotasks. The primary job of the event loop is to check the macrotask queue round by round and process these tasks.
For example:
setImmediate(() => {
console.log('#1');
});
setImmediate(() => {
console.log('#2');
});
setImmediate(() => {
console.log('#3');
setImmediate(() => {
console.log('#4');
});
});
The next time the immediate macrotask queue is checked, the three outer callbacks will be executed sequentially; the inner one won't run until the following check. Thus, the rule for macrotasks is like waiting for the next bus (the next iteration of the event loop, or a specific phase that hasn't occurred yet in the current iteration).
microtask
Microtasks, also known as "jobs." Examples include:
-
process.nextTick -
Promise callback
-
Object.observe -
MutationObserver
nextTick and Promises are common. Object.observe is a deprecated API for native observation, and MutationObserver has been around for a while, used to listen for DOM changes.
Generally, these callbacks are added to the microtask queue under certain conditions. They are checked and flushed (processed completely) immediately after the current macrotask finishes its execution.
P.S. "Generally" because Promise callbacks in some browser versions might not use the microtask queue, as the Promises/A+ specification doesn't strictly require it (stating that either is fine).
For example:
setImmediate(() => {
console.log('immediate');
});
Promise.resolve(1).then(x => {
console.log(x);
return x + 1;
}).then(x => {
console.log(x);
return x + 1;
}).then(x => console.log(x));
The next time the microtask queue is checked, it finds one Promise callback and executes it immediately. Checking again, another one has appeared, so it continues. Wait, another one is generated during that execution, so it keeps going. Finally, when the check finds it empty, the event loop continues to the immediate macrotask queue to execute the setImmediate callback. Therefore, the rule for microtasks is to hang onto the tail of the current 'bus' (turn), and they can be processed as they are created (executed as soon as the current macrotask finishes, without waiting for the next turn; and microtasks of the same type generated during the flush are handled immediately, allowing for potential blocking).
Event Loop
We know that the inherent asynchronous nature of JS is achieved via the Event Loop. For example:
const afterOneSecond = console.log.bind(console, '1s later');
setTimeout(afterOneSecond, 1000);
The execution process is roughly as follows:
-
The JS thread starts and creates the event loop.
-
The script is added to the call stack.
-
The first line executes to create a Function.
-
The second line executes, recording (via the Event Table) that the
afterOneSecondcallback should be processed after1000ms. -
The script exits the stack, and the call stack is now empty.
-
The event loop runs empty for a while (macrotask queue is empty, nothing to do).
-
After 1s+, the timer expires, and the
afterOneSecondcallback is inserted into the macrotask queue. -
The next iteration of the event loop checks the macrotask queue, finds it non-empty, and takes the
afterOneSecondcallback (first-in, first-out) to the call stack. -
afterOneSecondexecutes, logging1s later. -
afterOneSecondexits the stack, and the call stack is empty again. -
Nothing else will happen, so the event loop ends.
This is where it gets interesting. Regarding the end point of the event loop, a common misconception is:
JS code execution always happens within the event loop.
This is vague. In reality, the event loop only becomes active (checking task queues) when the call stack is empty. When it is confirmed that nothing more will happen, the event loop ends. For example:
// Write the above example to a ./setTimeout.js file
$ node ./setTimeout.js
1s later
The Node process executing ./setTimeout.js lived for about 1s and exited normally as the event loop ended. Server programs are different; for instance, a server listening on a port prevents the event loop from ending, so the Node process persists.
P.S. Each JS thread has its own event loop, so Web Workers have independent event loops as well.
P.S. The Event Table is a data structure used alongside the Event Loop to map callback triggers to their respective functions:
Every time you call a setTimeout function or you do some async operation?—?it is added to the Event Table. This is a data structure which knows that a certain function should be triggered after a certain event.
Purpose
So, what is the significance of the event loop? Can we do without it?
It exists to support asynchronous features. Consider that JS has been used in browser environments for many years, where UI interactions and network requests are relatively slow. Since JS runs on the main thread and can block rendering, if these slow actions were synchronous and blocking, the user experience would be terrible. For example:
document.body.addEventListener('click', () => alert(+new Date));
const xhr = new XMLHttpRequest();
// Sync xhr
xhr.open('GET', 'http://www.ayqy.net', false);
xhr.send(null);
console.log(xhr.responseText);
During the ~3 seconds it takes to execute send(), the page is completely unresponsive. Any alert boxes triggered by clicks during this time are inserted into the macrotask queue. Only after the request returns will these boxes pop up one by one.
Without an event loop, there would be no interaction during those 3 seconds, and the alert boxes would never pop up later. Thus, the event loop provides asynchronous capabilities to handle slow operations that would otherwise block rendering.
P.S. In fact, DOM event callbacks are macrotasks themselves, likewise relying on the event loop.
Call Stack
JS's single-threaded nature means only one thing can happen at a time, so there is only one call stack (per JS thread). For example:
function mult(a, b) { return a * b; }
function double(a) { return mult(a, 2); }
+ function main() {
return double(12);
}();
The call stack changes during execution as follows:
// push script
// push main
// push double
// push mult
// pop mult
// pop double
// pop main
// pop script
Note: The event loop only gets a chance to work when the call stack is empty. For example:
function onClick() {
console.log('click');
setTimeout(console.log.bind(console, 'timeout'), 0);
// Wait 10ms
let now = Date.now();
while (Date.now() - now < 10) {}
}
document.body.addEventListener('click', onClick);
document.body.firstElementChild.addEventListener('click', onClick);
document.body.firstElementChild.click();
The output of the above is:
click
click
timeout
timeout
The reason timeout doesn't follow the first click immediately is because the call stack is not empty (the child's onClick is still on the stack), so the event loop doesn't check the macrotask queue even though there is a callback from an expired timer. Specifically, since event bubbling triggers the onClick on body, the child's onClick cannot exit the stack until the synchronous bubbling chain finishes.
P.S. The interesting part here is the "implicit function call" brought about by event bubbling.
6 Task Queues
In Node.js, there are 4 macrotask queues (with a specific processing order):
-
Expired timers/intervals queue:
setTimeout,setInterval -
IO events queue: Callbacks for file I/O, network requests, etc.
-
Immediates queue:
setImmediate -
Close handlers queue: e.g., socket close event callbacks
The event loop starts checking from expired timers and processes all waiting callbacks in each queue sequentially.
Additionally, there are 2 microtask queues (also with a defined processing order):
-
Next tick queue:
process.nextTick -
Micro task queue: e.g., Promise callbacks
The nextTick microtask queue has higher priority than other microtask queues, so others like Promise are only processed when nextTick is empty.
Next tick queue has even higher priority over the Other Micro tasks queue.
nextTick vs setImmediate
The former is a microtask, while the latter is a macrotask. This means excessive consecutive nextTick calls will block the event loop and subsequently block I/O. Therefore, do not abuse nextTick unless necessary:
It is suggested you use setImmediate() over process.nextTick(). setImmediate() likely does what you are hoping for (a more efficient setTimeout(..., 0)), and runs after this tick's I/O. process.nextTick() does not actually run in the "next" tick anymore and will block I/O as if it were a synchronous operation.
Furthermore, the main difference is that nextTick executes at the tail of the current turn, whereas setImmediate waits for the next turn:
- process.nextTick() fires immediately on the same phase
- setImmediate() fires on the following iteration or 'tick' of the event loop
P.S. The description of setImmediate is not strictly precise; it can execute as soon as it reaches the next immediate phase, not necessarily the next iteration of the event loop (depending on the current phase).
P.S. Based on their names, immediate sounds closer, but nextTick is actually the nearest future. It's a matter of historical reasons that can't be changed now.
Note: nextTick exists to provide finer-grained tasks that can be executed in the gaps between the various phases of the event loop, such as for urgent cleanup, error handling, or retries. In other words, there are practical use cases; see Why use process.nextTick()? for more details.
setTimeout vs setImmediate
setTimeout(function() {
console.log('setTimeout')
}, 0);
setImmediate(function() {
console.log('setImmediate')
});
Given the timer-IO-immediate-close processing order for macrotasks, one might guess the log order is:
setTimeout
setImmediate
But the actual situation is:
// 1st
setImmediate
setTimeout
// 2nd
setImmediate
setTimeout
// 3rd
setImmediate
setTimeout
// 4th
setTimeout
setImmediate
// 5th
setImmediate
setTimeout
// 6th
setTimeout
setImmediate
The output is non-deterministic. This is not due to a race condition, but because the 0 in setTimeout 0 is not strictly "immediate." This means a 0ms timer might not immediately insert the callback into the task queue, potentially missing the nearest event loop turn. This leads to seemingly illogical output.
So, under what circumstances can the order be determined?
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0);
setImmediate(() => {
console.log('immediate')
})
});
New timer and immediate tasks generated during I/O queue processing will result in the immediate queue being processed next in sequence. Thus, 'immediate' will always be logged first, and the order will remain stable.
Is there a way to force the opposite order?
Yes, by doing this:
setTimeout(function() {
console.log('setTimeout')
}, 0);
//! wait for timer to expire
var now = Date.now();
while (Date.now() - now < 2) {
//...
}
setImmediate(function() {
console.log('setImmediate')
});
The above will consistently log setTimeout first. The 2ms block waits for the timer to expire, ensuring that the timer's callback is already in the pending queue before the event loop starts.
P.S. The reason for using 2ms is that setTimeout 0 is reportedly converted to setTimeout 1ms, so we wait just a little longer. For details, see the uvlib source code analysis in Understanding Non-deterministic order of execution of setTimeout vs setImmediate in node.js event-loop.
P.S. If 2ms isn't enough, wait a bit longer. The key is to wait for the timer to expire so the event loop sees the setTimeout 0 callback immediately in the first turn rather than waiting for the next.
IO starvation
The microtask mechanism brings about the I/O starvation problem: an infinitely long microtask queue will block the event loop. To avoid this, early versions of Node.js (v0.12) set a depth limit of 1000 (process.maxTickDepth), which was later removed.
process.maxTickDepth has been removed, allowing process.nextTick to starve I/O indefinitely. This is due to adding setImmediate in 0.10.
P.S. See https://github.com/nodejs/node/wiki/API-changes-between-v0.10-and-v0.12#process for details.
For example:
const fs = require('fs');
function addNextTickRecurs(count) {
let self = this;
if (self.id === undefined) {
self.id = 0;
}
if (self.id === count) return;
process.nextTick(() => {
console.log(`process.nextTick call ${++self.id}`);
addNextTickRecurs.call(self, count);
});
}
addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
console.log('omg! file read complete callback was called!');
});
console.log('started');
omg! xxx will never be logged. After the synchronous code finishes, the call stack is empty. The event loop checks the nextTick microtask queue, finds it non-empty, pulls the microtask, executes its callback on the call stack, which then inserts another one, creating an endless cycle.
Note that the nextTick queue is checked immediately, regardless of the current phase of the event loop:
the nextTickQueue will be processed after the current operation completes, regardless of the current phase of the event loop.
(Quote from The Node.js Event Loop, Timers, and process.nextTick())
Event Loop Counter
How do you count iterations of the event loop?
You can do this:
const LoopCounter = {
counter: 0,
active: true,
start() {
setImmediate(this.countLoop.bind(this));
},
stop() {
this.active = false;
},
get() {
return this.counter;
},
countLoop() {
this.counter++;
if (this.active) setImmediate(this.countLoop.bind(this));
}
};
// test
LoopCounter.start();
let now = Date.now();
let intervals = 0;
let MAX_COUNT = 10;
let handle = setInterval(() => {
console.log(LoopCounter.get());
if (++intervals >= MAX_COUNT) {
clearInterval(handle);
LoopCounter.stop();
}
}, 10);
setImmediate is used as the clock because among the four types of macrotasks, only setImmediate guarantees processing in the very next iteration of the event loop.
What is the use of this counter?
It can be used to track the event loop, for instance, to confirm if tasks occur within the same turn. The earlier discussion on the order of setTimeout 0 vs setImmediate can be further verified with this counter. The results are as follows:
// 1st
setImmediate 1
setTimeout 1
// 2nd
setTimeout 0
setImmediate 1
1 1 indicates that the timer missed the first iteration and was executed in the second. 0 1 indicates that the timer had already expired before the first iteration began (it caught it successfully).
References
-
Difference between microtask and macrotask within an event loop context
-
Philip Roberts: Help, I’m stuck in an event-loop.: A video about the event loop, call stack, and task queues.
-
loupe: A visualization tool for JS execution.
-
Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 2: The last question is very interesting, with the answer in the comments. This series of 5 articles should be excellent.
No comments yet. Be the first to share your thoughts.