JavaScript under the hood - Promises
What do we mean when we say JavaScript is synchronous ? In simple words, this means that JS will execute your code one task at a time, and only when that task has finished executing, it will move onto the next task.
Let’s look at a simple example:
function taskOne() {
console.log("task one done!");
taskTwo();
}
function taskTwo() {
console.log("task two done!");
}
function taskThree() {
console.log("task three done!");
}
taskOne();
taskThree();
Here’s the order in which the console.log statements are printed out:
"task one done!";
"task two done!";
"task three done!";
In the example above, there’s absolutely no way taskThree will execute until taskOne has been completed (this is what we mean by synchronous code).
In more technical terms, taskThree will only be pushed onto the call stack once taskOne and taskTwo have finished executing and are popped off the call stack. Well, so what exactly is the callstack ?
The callstack is a stack data structure that the JS engine uses to keep track of all the functions that are currently executing. When the JS engine comes across a function execution it pushes that function onto the call stack and begins executing the function. The function will remain on the stack for as long as it’s executing, and once it’s finished, it gets popped off the stack.
The diagram below shows what the call stack looks like for the aforementioned code from the moment the JS engine begins the execution phase until it’s done. Note that the values t0,t1,t2,etc.. represent arbitrary units of time, where t1 is greater than t0, and so on.
As per our diagram above, the JS engine starts executing our code at time t0, which is depicted by the purple function called global. At t1, it comes across the following line of code taskOne(), and since this is a function call, the JS engine pushes taskOne on the call stack so that it can be executed. As the JS engine is executing taskOne, it comes across the following line of code: taskTwo - this function is then pushed onto the call stack at time t2. Sometime later (t3) taskTwo is finished executing and is popped off the call stack and shortly after taskOne finishes executing and is pushed off the callstack (t4). The same process is repeated for taskThree and by time t7 the callstack is empty; which means that all our global code has finished running and our JS program is “idle”.
Now what do you think happens if we make a small change to taskOne by adding setTimeout to this function ?
function foo() {
console.log("task one done!");
}
function taskOne() {
setTimeout(foo, 0); //<---!!!!!!!Add setTimeout and set delay to 0 milliseconds!!!!!
taskTwo();
}
function taskTwo() {
console.log("task two");
}
function taskThree() {
console.log("task three");
}
taskOne();
taskThree();
Here’s the order in which the console.log statements are printed out:
"task two done!";
"task three done!";
"task one done!"; //<-- Note that task one is logged last
You might be wondering why “task one done!” was logged last since we passed 0 as the delay to our setTimeout function. In order to answer this question we need to talk about two aspects of JavaScript that work behind the scenes: The Callback Queue (aka The Task Queue) and The Event Loop.
Here’s an interesting trivia: setTimeout isn’t actually implemented in JavaScript - it’s merely a wrapper function around the browser’s Timer function; JS will pass the arguments you provide to it when you call setTimeout (which are usually the callback function and the delay), which will start a countdown and return the callback when the the timer expires and place it in the callback queue. The callback queue is a queue data structure where functions that are awaiting execution are enqueued. These functions are dequeued from the callback queue by the event loop and then put on the call stack, where they are executed.
The event loop is a looping process in JS that’s responsible for dequeuing functions from the callback queue and pushing them onto the callstack, BUT only when the callstack is empty. So the event-loop loops through the following process:
- Check whether the callstack is empty ? If no: repeat this step.
- Else check whether the microtask queue (more on this later, I promise) is empty. If no: dequeue a task and repeat step 1 again.
- Else check whether the callback queue is empty. If no: dequeue a task and repeat step 1.
- Repeat step 1.
The event loop is basically repeating the four steps outlined above throughout your JS app’s entire life-cycle! Based on the above we can derive the following principle: A function doesn’t get dequeued from the callback/microtask queue and put on the callstack unless the callstack is empty. So if the callstack is never empty, the JS runtime will not check the microtask queue and the callback queue. If the callstack is empty but the microtask queue is never empty, the JS runtime will never check the callback queue, so it could be the case that certain functions are just never run!
Going back to our example, here’s what the callstack and the callback queue look like throughout the lifecycle of our program:
In the diagram above, we see that at t0 our JS file starts getting executed and at which time the callback queue is empty. Then note that at t2 foo gets enqueued onto the callback queue, and gets dequeued at time t8 at which point it gets added to the callstack and is executed!
You can think of promises as double-pronged functions that do two things simultaneously:
Like setTimeout, a Promise will invoke some functionality in the browser’s API and we pass to the browser the callback we’d like to run when the browser has finished completed it’s task, and because of this the browser will communicate the response to the JS engine asynchonrously. Unlike setTimeout, a Promise will immediately return a special object (known as a Promise object) that will contain the property value which will be undefined initially, but will automatically be filled with the resolved value (which is passed to the resolve callback function). The promise object also contains a property called onFulfillment, which is an array that contains all functions that will be triggered when the value property on the object is filled. OnFulfillment is a hidden property that’s only available to the JS engine but we specify all callbacks we want to trigger on the fulfillment of a promise by passing them to the then property. Let’s look at some code to deepen our understanding of promises.
//define a simple promise function that immediately resolves
function promise() {
return new Promise(function (resolve, reject) {
resolve("Hi!");
});
}
function onPromiseComplete(data) {
console.log("finished promise with data:", data);
}
function runPromise() {
promise().then(onPromiseComplete);
}
function onTimeoutComplete() {
console.log("finished timeout!");
}
function runTimeout() {
setTimeout(onTimeoutComplete, 0);
}
console.log("Script start");
runTimeout(); //execute our setTimeout function
runPromise(); //execute our promise function
console.log("Script end");
The code above will log the following:
"Script start";
"Script end";
"finished promise with data: Hi!"; //<-- hmmm, interesting ?
"finished timeout!";
The fact that runPromise is completed before runTimeout even though it was executed after runTimeout may be confusing to a user, but only if they are uninitiated with the microtask queue. As alluded to earlier, JS has a microtask queue, which has a higher priority than the callback queue, meaning that the event loop will only check the callback queue once the microtask queue is empty!
In the diagram below we can see how the aforementioned code is executed. Note that onTimeoutComplete is put on the callback queue at t1 and onPromiseComplete is put on the microtrask queue at t2. Nevertheless, onPromiseComplete is pushed on the callstack and executed before onTimeoutComplete at t4 !
There you have it - the above is the asynchonrous model of JavaScript; a beautiful interplay between the Event Loop, Callback Queue and Microtask Queue.