If you work with JavaScript, you know there are so many libraries that you don’t even need to bother learning the “weird” parts of the language and internal mechanisms to do the job. But if you plan to grow as a real professional and get onto a completely new level of seniority – you must know those things. Event loop and microtasks – are some of them, and I hope this article will help you to understand how it all works.
Let’s look into this example:
setTimeout(() => { console.log(‘first’); }, 0);
console.log(‘second’);
What do you expect to see in the console? It seems like because we’re waiting for 0 milliseconds we should see "first" before "second", but in reality, you get "second" before "first". If you have troubles in explaining why does it happen – dive with me deep to the underground world of a JavaScript engine.
As you have probably heard, JavaScript has a single Call Stack, which is needed to keep track of functions we’re currently executing and to know what will be executed next. A “stack” is an array-like data structure, which is similar to a pile of plates: you can only put them on top of each other, and in any time you can remove only the top one.
When you want to execute a function, it’s added to the top of the Call Stack. Then if that function calls another function, the other function is placed on top of the first function. If you remember how does look like a stack trace when you get an error in the console – that’s exactly how did the Call Stack look in the time when the error happened.
But what happens if you call setTimeout() or make a request? We can’t execute the next function from the Call Stack until the previous function hasn’t finished yet. This should freeze the browser while our request is in flight, but we know it doesn’t happen. And the reason – Event Table and Event Queue.
When you call an asynchronous function like setTimeout(), it’s added to the Event Table. Think about it as a table-like data structure, which keeps track of what function should be triggered after a certain event. It doesn’t execute the function or add to the Call Stack once the event (touchstart, timeout, click) happens. It only sends the function to the Event Queue.
Event Queue – is a queue data structure, where you can add an item to the back and remove an item only from the front. All it does – helps keep the order of callbacks in which they should be executed. Again, it doesn’t execute functions or add them to the Call Stack on its own, only stores them in order. And here comes the Event Loop.
Now you know enough to understand what is the Event Loop. It’s a constantly running process (loop) that checks if the Call Stack is empty. If it’s not empty – Event Loop executes the top task in the Call Stack, and if it is empty – looks into Event Queue and moves callbacks from there to the Call Stack. If the Call Stack and Event Queue are both empty – Event Loop does nothing. This is called a “tick”. On each tick this sequence repeats.
Each “thread” in JS gets its own Event Loop (each web worker gets its own EL), so it can execute independently. All browser windows on the same origin share the same Event Loop so that they could synchronously communicate.
Remember our puzzle with logs?
setTimeout(() => { console.log(‘first’); }, 0);
console.log(‘second’);
Now I’m sure you can explain why “second” is printed before “first”. When JavaScript sees a setTimeout() it adds your callback to the Event Table. Then JS engine immediately continues executing and prints “second” to the console. Now when the Call Stack is empty (we have no code to run), Event Loop checks Event Queue and finds there your timeout callback, moves it to the Call Stack and then executes it in the next iteration.
There is another good example of how useful can be the knowledge of internal JavaScript mechanisms: imagine you need to do a big number of recursive calls, but you get a “Stack overflow” error message because there is a limit of how big can be the Call Stack. What you can do – wrap your recursive call into setTimeout(recursiveFunction(), 0)
. Thus you move all the recursive calls to the Event Table and avoid directly piling up the Call Stack. It’s better to restrain from these hacks, but it’s a good demonstration of how JS engine works.
For each loop of the Event Loop, one task is completed out of the Call Stack. But it turns out that not all tasks are equal. Some are considered as macrotasks and some – as microtasks. And – surprise! – they occupy two different stacks and never mix.
Event Loop first visits the main stack with macrotasks, executes one function from the top and then visits the microtasks queue and executes all functions from there before moving on. The Call Stack – is the macrotasks Stack, and since we are executing the entire microtasks queue on each macrostask, we can say that every macrotask has its own microtasks queue.
Examples of macro: I/O tasks, setTimeout, setInterval, setImmediate.
Examples of micro: promises, process.nextTick.
Wait, what?... Promises are considered as microtasks? Yes, because if they were macro – then a chain of promises would require multiple loops of the Event Loop to complete – not the best variant for promises.
Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task.
Now look at this code and try to predict the result in the console:
console.log('start');
setTimeout(() => {
console.log(‘timeout’);
}, 0);
Promise.resolve()
.then(() => {
console.log('promise 1');
})
.then(() => {
console.log('promise 2');
});
console.log('end’);
The correct answer here is: “start”, “end”, “promise 1”, “promise 2”, “timeout”. Confused? Let’s go step by step to understand why:
1. console.log('start')
prints “start” to the console.
2. setTimeout()
schedules an event in the Event Table. Event Table keeps track of this event and once it happens (in 0 ms, that is immediate) it puts the callback to the Event Queue.
3. Promise.resolve()
settles a promise and all subsequent then() callbacks are treated as microtasks, so we add them both to the microstasks queue for the currently running task.
4. console.log('end’)
prints “end” to the console.
5. We finished all macrostasks from the Call Stack and it’s time to go visit the microtasks queue.
6. () => { console.log('promise 1'); }
prints “promise 1” to the console.
7. () => { console.log('promise 2'); }
prints “promise 2” to the console.
8. We finished the entire microtasks queue. It’s time to go check the Event Queue.
9. In the Event Queue we find the timeout callback and move it to the top of the Call Stack.
10. The “tick” is over.
11. In the next iteration of the Event Loop, we again take the task from the top of the Call Stack, and it’s () => { console.log(‘timeout’); }
which prints “timeout” to the console.
12. The macrotasks are completed, let’s check the microtasks queue. It’s empty? Perfect! Not let’s check the Event Queue. Also empty.
13. The program is done!
This can be a new mind-blowing concept for you, if you’ve just learned about all of these first time. But congratulations, you’ve survived and know enough to build your own JavaScript engine. Or at least now you have something to talk about during a coffee break with collegues.
Useful materials: