JavaScript in depth: execution contexts and event loop (3 of 4).
How event loop works and how it interacts with the JavaScript engine.
This is the third part of a four article mini-series. You can read the first part here and the previous part here.
Introduction
Before starting, let’s summarize the key points we analyzed in the previous part that will be useful to better understand this article:
- JavaScript is a single-thread programming language, meaning it can execute only one instruction at a time.
- The process responsible for actually executing JS code is called JavaScript engine, and it consists of two main components, the Heap (where references to objects and data structures are dynamically stored) and the Call Stack, which keeps tracks of the order of instructions that need to be executed.
- The JavaScript engine is in turn part of an even larger process handled called the JavaScript runtime environment, in which there are some extra-functionalities (in a browser the Web APIs) to which the JavaScript engine delegates management.
- One of the primary responsibilities of the JavaScript runtime is to ensure that asynchronous operations, such as server requests, do not block the execution thread. Failure to do so, particularly in a JavaScript runtime like a browser, can result in unresponsive web pages, making your users frustrated and upset.
- In order to achieve the goal of the previous point, a process called Event Loop runs continuously inside the JavaScript runtime environment to coordinate the execution of synchronous and asynchronous tasks inside the JavaScript engine, enabling a concurrency model.
How does the event loop work?
Beginning with the most fundamental explanation, when a JavaScript runtime is instantiated, the Event Loop is created and begins running alongside other components such as the JavaScript engine, the DOM, and all other Web APIs. Among these components, there is another crucial data structure we have not yet mentioned: the Task Queue, sometimes also referred to as the Callback Queue (referring to picture above is “Queue”).
We can define the Task Queue as the container of the asynchronous operations that are ready to be executed. So the job of the event loop is to continuously monitor both the Call Stack of the JavaScript engine and the Task Queue. If there are elements (callbacks) in the Task Queue ready to be executed, the event loop pushes them in the call stack for execution. The elements in the Task Queue are processed with a FIFO (First In, First Out) approach
The event loop iterates over an ordered list of phases, which depend on the specific JavaScript runtime environment. For example, the phases of the event loop in Node.js differ from those in the Google Chrome browser. Each iteration of the cycle is technically defined as a “tick”. An important concept to understand is however that the callbacks added to the Task Queue after an iteration of the event loop starts won’t run until the next iteration of the event loop happens.
Look at the following code for example:
//Simulate a fetch request that will take 1 second for the response to arrive
const simulateCallToServer = async () => {
const response = new Promise((resolve) => setTimeout(() => {
resolve("Data arrived!"), 1000));
}
console.log(await response);
}
simulateCallToServer();
//Simulates an event listener attached to a button
document.addEventListener("userClickedAButton", ()=> {
console.log("The user has clicked something!");
})
//The setTimeout will put the callback in the Task Queue, even if the timer
//is 0
setTimeout(() => {
document.dispatchEvent(new Event("userClickedAButton"));
}, 0)
//The for loop will run synchronously, causing the web page to block
//until the loop ends
for (const num of Array.from({length: 1000}).map((_, index)=> index) {
console.log(num)
}
console.log('I have to wait the end of the for loop to be executed');
When the code runs the following things happen:
- When the
simulateCallToServer()
function is called, since it is aPromise
, it will be handled asynchronously. Until the promise ispending
, the JavaScript engine will keep track of its status internally without making any further operation. - When the
"userClickedAButton"
event is fired, since it is wrapped inside a timer, it is pushed into the Task queue. Timers such assetInterval()
andsetTimeout()
are always sent to the Task queue, even if the timer duration is 0. Furthermore, since an iteration of the event loop is already running, it will be executed as soon as the Call Stack of the JavaScript engine is empty (after the lastconsole.log()
). - The
for
loop is executed synchronously, and the JavaScript engine will wait until the end of thefor
loop to execute the next statement. - After the
console.log("I have to wait the end...")
, the call stack is now empty, a new tick of the event loop runs. The event listeneruserClickedAButton
can finally be executed, and the event loop pushes it into the JavaScript engine call stack, printing"The user has clicked something”
to the console. - What happened to the
simulateCallToServer()
promise? Since the timer hasn’t elapsed yet, it has to wait at least 1000ms before being inserted into the Task Queue. At that point, since the Call Stack is already empty, it will be immediately processed, printing"Data arrived!"
as the last statement of the code.
Now that we understand how asynchronous programming works in JavaScript, In order to be fully confident in solving the problem that was seen in the part 1, we need to analyze in depth how the JavaScript engine’s Call Stack stack works. We will see that it operates with a logic diametrically opposite to FIFO, and each element inside the call stack has a hierarchical relationship used to resolve scope, variables, and references to the outer lexical environment. I hope to see you there!
Side Notes
The general architecture presented in this article has been slightly simplified in order to make you understand the basics of how JavaScript handles asynchronous code. In reality, there is more than one Queue in which asynchronous callbacks are pushed, but the way the event loop works remains pretty much the same. If you want to dive deeper you can read this resource.