We may have heard that JavaScript executes all the operations on a single thread, but what is the magic empowering JavaScript to be an asynchronous programming language? Event Loop is the rationale and forms the non-blocking feature like Node.js.
First, we must keep in mind that Event Loop is related to the runtime environment, such as the browser or Node.js, not the JavaScript Engine(V8).
To be more specific, Event Loop is an endless loop that waits for tasks, executes them, and then sleeps, waiting for more tasks. It ensures all the tasks from WebAPIs(Browser) or C++ APIs(Node.js) could queue up for being pulled into the stack and finally become executed. If you first get to know this topic and still feel a bit fuzzy, donât worry, I will use the following graph to explain. đ€
Basic Architecture
To execute an async function, the browser would utilize the four vital steps:
- Add a function into Call Stack.
- Handle the browser-related method in WebAPIs.
- Queue all the tasks in Task Queue.
- Pull a task to Call Stack one at a time by Event Loop till the queue is empty.
PS: To make the whole story short, we only focus on the âStackâ in the first step. If you are interested in how the V8 JavaScript Engine works, you could check out the previous article đ„ Cracking JavaScript Series: JavaScript Engine.
Call Stack
It represents the order of JavaScript code execution and keeps track of its place in a script that calls multiple functions. Whenever a function is finished, it is popped from the stack. It is a LIFO queue (Last In, First Out).
- Enqueue
- Dequeue
By the way, an anonymous function is the default function executed first in the Call Stack. It usually appears as a
main()
orglobal()
function in the dev-tool of the browser. - Whenever an error occurs, the browser would also show its stack position.
- Stack Overflow Some may ask: âhow many recursive function calls causes stack overflow?â There is no standard answer, and it depends on your environment and function complexity. Here on OS X 10.13, Iâm limited to 8192K by default.
WebAPIs
Since V8 engine does not include âsetTimeoutâ, how does the browser execute it?
JavaScript did not implement the APIs for web operations, but the browser did, and it could be divided into Browser Object Model (BOM) and Document Object Model (DOM). Therefore, a standard JavaScript would contain the following three components:
- JavaScript Core (based on ECMAScript standard)
- BOM (Browser Object Model)
- DOM (Document Object Model)
For example, setTimeout()
method is defined on the WindowOrWorkerGlobalScope mixin(interface) and implemented by Window and WorkerGlobalScope. Those implementations belong to Web Workers API so that we could use window.setTimout()
or directly call setTimout()
on our browsers. The followings are other methods that you may be familiar with:
- WindowOrWorkerGlobalScope.atob()
- WindowOrWorkerGlobalScope.btoa()
- WindowOrWorkerGlobalScope.clearInterval()
- WindowOrWorkerGlobalScope.createImageBitmap()
- WindowOrWorkerGlobalScope.fetch()
- WindowOrWorkerGlobalScope.queueMicrotask()
- WindowOrWorkerGlobalScope.setInterval()
Task Queue
Task Queue could be the most complex but interesting field of asynchronous execution concept. For a better understanding, I would break Task Queue into three parts to elaborate.
- Definition of Task
- Advanced: Macrotask and Microtask
- Advanced: Odd Case of the combination of Macrotasks and Microtasks.
If you want to get a quick answer, part 1 would be enough, but if you still have time, I would suggest you dive into part2-3. :)
Definition of Task
A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired. These all get scheduled on the task queue.
Tasks get added to the task queue when:
- A new JavaScript program or subprogram is executed (such as from a console, or by running the code in a <script> element) directly.
- An event fires, adding the eventâs callback function to the task queue.
- A timeout or interval created with
setTimeout()
orsetInterval()
is reached, causing the corresponding callback to be added to the task queue.
Keep in mind that the functions in the callback queue start getting executed only after:
- We have reached the end of the program
- There are no functions left to be executed in the call stack
Here comes an example:
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");
bar();
foo();
baz();
Whatâs the output?
"First"
"Third"
"Second"
- We invoke
bar
.bar
returns asetTimeout
function. - The callback we passed to
setTimeout
gets added to the Web API, thesetTimeout
function andbar
get popped off the callstack. - The timer runs, in the meantime
foo
gets invoked and logs First. foo returns (undefined),baz
gets invoked, and the callback gets added to the queue. baz
logs
Third. The event loop sees the callstack is empty afterbaz
returned, after which the callback gets added to the call stack.- The callback logs
Second
.
Basically, the setTimeout needs to wait for all the code for queued messages to complete even though you specified a particular time limit for your setTimeout.
- How about other WebAPIs like GlobalEventHandlers.onclick?
If we bind a click listener on a dom, it would be hooked on the WebAPIs and wait for triggering.
If we click too many times, the listener functions would be added to the callback queue, and whenever the call stack is empty, the event loop would pull another task from the callback queue to add it to the call stack for executing the code.
The event loop proceeds to execute all the callbacks waiting in the task queue. Inside the task queue, the tasks are broadly classified into two categories, namely micro-tasks and macro-tasks.
Macrotask and Microtask
The previous section explains the order between sync and async function.
Likewise, the browser also prioritizes async functions according to two task types: Macrotask and Microtask.
Macrotasks
- Macro-task represents some discrete and independent work. These are always the execution of the JavaScript code and micro-task queue is empty. Macro-task queue is often considered the same as the task queue or the event queue. However, the only small difference between the two is that the task queue is used for synchronous statements whereas the macro-task queue is used for asynchronous statements.
- In JavaScript, no code is allowed to execute until an event has occurred. It is worth mentioning that the execution of a JavaScript code execution is itself a macro-task. The event is queued as a macro-task. When a (macro) task, present in the macro-task queue is being executed, new events may be registered and in turn, created and added to the queue.
- In comparison, the macro-task queue has a lower priority. Macro-tasks include parsing HTML, generating DOM, executing main thread JavaScript code, and other events such as page loading, input, network events, timer events, etc.
Examples: setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI Rendering
Microtasks
- A micro-task is said to be a function that is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the scriptâs execution environment. A Micro-task is also capable of en-queuing other micro-tasks.
- Micro-tasks are often scheduled for things that are required to be completed immediately after the execution of the current script. On completion of one macro-task, the event loop moves on to the micro-task queue. The event loop does not move to the next task outside of the micro-task queue until all the tasks inside the micro-task queue are completed. This implies that the micro-task queue has a higher priority.
- Once all the tasks inside the micro-task queue are finished, only then does the event loop shift back to the macro-task queue. The primary reason for prioritizing the micro-task queue is to improve the user experience. The micro-task queue is processed after callbacks given that any other JavaScript is not under mid-execution. Micro-tasks include mutation observer callbacks as well as promise callbacks.
Examples: process.nextTick, Promises, queueMicrotask, MutationObserver
Whatâs the output of the following code:
setTimeout(function a(){console.log("timeout")});
Promise.resolve()
.then(function b(){console.log("promise")});
console.log("global ex. context");
The answer would be:
"global ex. contenxt"
"promise"
"timeout"
However, in the real scenario, nuance always exists and it may not match our understanding of the rule âMicro Queue is prior than the Marco Queue.â
Odd Case of the combination of Macrotasks and Microtasks
function display(data){console.log("Fetch resolved!")}
function printHello(){console.log("Callback Time")}
function blockExecution() {
console.log("Blocking execution...");
alert("Dont close the alert until 2000ms");
}
const futureData = fetch("https://jsonplaceholder.typicode.com/todos/1");
futureData.then(response => response.json()).then(display);
setTimeout(printHello, 2000);
const p = new Promise(
(resolve, reject) => {
console.log("I'm making a promise...");
resolve("Promise resolved!");
console.log("Promise is made...");
});
p.then(
// this is called the success handler
result => console.log(result)
);
blockExecution();
console.log("Execution ended!");
- Q: Whatâs the output?
I'm making a promise...
Promise is made...
Blocking execution...
1. Me first !
hi back from fetch
hello
actual output :
2. Me first !
hello
hi back from fetch
- A: Both 1, 2 could be the answer!!
âïžNote: If the blockExecution()
function block the thread long enough (over 2000ms), the printHello()
function in the âMarco Queueâ would be earlier pulled to the âCall Stackâ than the display()
function in the âMicro Queueâ, so the answer would be:
I'm making a promise...
Promise is made...
Blocking execution...
Execution ended!
Promise resolved!
------
Callback Time
Fetch resolved!
- Vice versa, if the
blockExecution()
function does not block the thread long enough(below 2000ms), the result would be:
I'm making a promise...
Promise is made...
Blocking execution...
Execution ended!
Promise resolved!
------
Fetch resolved!
Callback Time
- Surprisingly, it seems like the first circumstance violates the rule. Nevertheless, both cases strongly prove the truth of the rule. Remember, the âEvent Loopâ would always try to fill the call stack whenever the stack is empty. If a Microtask does not even finish its job (get a response from
fetch()
), and the Macrotask has completed its work (setTimeout() get executed as soon as the delay time reach 2000ms), the Event Loop would pull the Macrotask first to the call stack. What a workaholic machine, right?đ .
How to define a âBlockingâ code?
When people say donât block the event loop, itâs saying donât put shitty slow code on the call stack because when you do that, the browser can not create a nice fluid UI.
- Run the blocking code synchronously. (forEach)
- Run the blocking code asynchronously. (setTimeout)
- Since the render queue get a higher priority than the callback queue (repaint in every 16.6 milliseconds: 60 frames/ per second), the difference between the two is that the asynchronous method gives render a chance within each element.
Conclusion
We have learned about the JavaScript event loop which is a constantly running process that coordinates the tasks between the call stack and callback queue to achieve concurrency.