In this blog, we will delve deeper into how setTimeout works under the hood.
Let’s start with the basics of the setTimeout() function.
setTimeout()
It is a function used in JavaScript to delay the execution of the code. It's divided into 3 parts.
setTimeout(() => {
console.log('hello world');
}, 1000)
- Callback function (first argument)
- Statements to be executed inside callback function (consoles inside first arguments)
- Delay time (second argument, time in milliseconds)
The above code will print hello world after 1s (1000 ms).
While this seems straightforward, execution under the hood is interesting.
Let’s check how setTimeout works in the background.
Example 1: Simple setTimeout()
let a = 5;
let b = 10;
console.log('Me first');
setTimeout(() => {
console.log('print after 1ms', a,b);
},1000);
console.log("Multiply =", a*b);
Output:
Me first
Multiply = 50
// after 1ms time delay
a = 5
b = 10
The above code has setTimeout of 1ms for console.log(a) and console.log(b). Hence, first Me first and multiply = 50 will be output, and after a delay of 1ms, a = 5 and b = 10 will be printed. This is because blocking statements would run first and after all blocking statements execute, non-blocking statements would begin executing. Here's a quick primer on blocking and non-blocking statements.
Example 2: Understand Blocking and Non-Blocking Statements
let nums = [1,2,3];
console.log("Blocking statement 1");
setTimeout(() => {
console.log(nums.length);
console.log("non-blocking");
},1000)
nums.forEach((num) => {
console.log(num);
});
setTimeout(() => {
console.log("after 0 secs");
}, 0);
console.log("blocking");
Output:
Blocking statement 1
1
2
3
blocking
after 0 secs
3
non-blocking
What we can learn from the above example is that all the “Blocking” statements other than setTimeout are executed first. “Blocking” statements prevent the next statement from running until its execution finishes. Functions like setTimeout(), setInterval(), promises, network calls, events, and all other asynchronous calls are non-blocking statements.
Call Stack:
The Call Stack is responsible for executing blocking statements first and all those statements which come from the event queue.
Event Queue:
Event Queue is a waiting area for the statements exiting the event loop after a specific delay time and are waiting to execute when the call stack finishes execution of all the blocking statements.
Event Loop:
It handles all the asynchronous tasks that have been added inside the code. These statements go to the event queue after the specified delay time.
We’ll use an analogy to better understand the above example. Consider a railway station with:
- Ticket checker as call stack
- Ticket window as an event loop
- Waiting queue as event queue
Where the ticket checker (call stack) checks for valid tickets (blocking statements) and allows ticketed passengers to board the train and moves those who do not have valid tickets (non-blocking statements) to the ticket window to get a valid ticket (Event loop). Once they have a valid ticket from the ticket window (perform specified delay), these passengers move to the waiting queue (Event queue) to again validate their tickets with the ticket checker (Call Stack) and board the train.
Example 3: Nested setTimeout()
console.log('outside setTimeout1');
setTimeout(() => {
console.log('inside parent1 setTimeout1');
setTimeout(() => {
console.log('inside parent2 setTimeout1');
setTimeout(() => {
console.log('inside child setTimeout');
}, 3000);
console.log('inside parent2 setTimeout2');
}, 2000);
console.log('inside parent1 setTimeout2');
}, 1000);
console.log('outside setTimeout2');
Output:
outside setTimeout1 // blocking statement execute first outside setTimeout2 // blocking statement execute first inside parent1 setTimeout1 // after 1000ms blocking statement inside parent1 scope inside parent1 setTimeout2 // after 1000ms blocking statement inside parent1 scope inside parent2 setTimeout1 // after 2000ms blocking statement inside parent2 scope inside parent2 setTimeout2 // after 2000ms blocking statement inside parent2 scope inside child setTimeout // after 3000ms blocking statement inside child scope
As discussed earlier in the blog, the blocking statements are output first. After that, setTimeout waits for 1 sec in the event loop and then moves to the event queue, and finally arrives at the Call Stack and is executed.
Since both consoles inside setTimeout are considered as blocking statements, they execute first, and the inner setTimeout goes to the event loop for 2 secs and waits in the event queue before moving to the call stack and executing.
I hope the nested setTimeout execution is clear based on the above example.
Example 4: SetTimeout() with loops
for(var i=0; i<=5; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
Output:
6
6
6
6
6
6
While readers may be expecting “0,1,2,3,4,5” to be output, instead the output is “6” repeated six times. This is because the “for loop” is a blocking statement and setTimeout is non-blocking. Hence loop creates 6 setTimeout and moves it to the event loop and while those setTimeout are waiting in the event queue, the “for loop” cycles through the execution and the value of I is reset to 6.
Hence when setTimeout arrives at the call stack for execution, it fetches i=6 and outputs “6” six times. We can avoid this problem by using “let” instead of “var” in the “for loop," because “let” has a block scope and that's why each setTimeout remembers the correct value of i.
Let’s try the same example using “let”
for(let i=0; i<=5; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
Output:
0
1
2
3
4
5
Another solution is to create a function scope using var
for(var i=0; i<=5; i++) {
const func = (x) => {
setTimeout(() => {
console.log(x);
}, 1000);
};
func(i);
}
Output:
0
1
2
3
4
5
Understand the Promise Object
The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value. This lets asynchronous methods return values like synchronous methods. Instead of immediately returning the final value, the asynchronous method returns a promise to supply the value at some point in the future.
A Promise is in one of the following three states:
- pending: initial state, neither fulfilled nor rejected.
- fulfilled: meaning that the operation was completed successfully.
- rejected: meaning that the operation failed.
We can access the “fulfilled” value of Promise using the “then ()” method, while the “catch ()” method provides the rejected value.
Here's a simple example,
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('foo');
}, 1000);
});
Note: This blog does not cover Promise in-depth. The purpose of this blog is to understand micro-task versus macro-task To understand how setTimeout and Promise work inside the JS engine and which has a higher priority to execute first, consider the following example:
let print3 = async()=>{
return new Promise((resolve,reject)=>{
setTimeout(()=>{
console.log("3");
resolve();
},100)
})
}
let print = async () => {
console.log("1");
setTimeout(() => {
console.log("5");
}, 200);
return new Promise(async (resolve,reject)=>{
console.log("2");
print3();
resolve();
console.log("4");
})
};
let start=async()=>{
print();
console.log("done")
};
start();
Output:
1
2
4
done
3
5
Output Step-by-Step
Remember the event loop keeps checking if the call stack is empty or not. If the event loop finds the call stack empty, it executes the tasks waiting in the event queue.
The event queue has two types of tasks.
Macro-tasks and Micro-tasks
- setTimeout() is a Macro-task with lower priority
- Promise is a Micro-task with higher priority
Micro-tasks have higher priority than the Macro-tasks.
let print3 = async()=>{
return new Promise((resolve,reject)=>{ // This line executes 7th and add callback function to the mictotask queue.
setTimeout(()=>{ // microtask queue content: console.log(2) and setTimeout(console.log("3"), 100)
console.log("3"); // this line moved to the end of the macrotask,
resolve();
},100) // macrotask queue content: console.log("5") in 200ms console.log("3") in 100ms
})
}
let print = async () => {
console.log("1"); // This line executes 3rd and print 1
setTimeout(() => { // This line executes 4th and add call back function inside macrotask queue.
console.log("5"); // macrotask queue content: console.log("5")
}, 200);
return new Promise(async (resolve,reject)=>{ // This line executes 5th and add callback function to microtask queue.
console.log("2"); // microtask queue content: console.log("2")
print3(); // This line executes 6th and call print3 function
resolve();
console.log("4"); // this line executes 8th and print 4
})
};
let start=async()=>{
print(); // This line executes 2nd and call print function
console.log("done") // this line executes 9th and print done
};
start(); // This line executes 1st and call start function
Let’s review the execution sequence and the output.
- Begin with the start () function
- It calls the print () function
- Now inside the print function, the call stack will check for all the blocking statements and execute those first. Then, the blocking statement “console.log (“1”)” gets executed and the output is “1”.
- The next statement is setTimeout() that adds the callback function to execute (console.log (“5”), 200ms) to Macro-task and proceed to the next statement.
- The next statement is Promise, also a non-blocking statement and hence the callback function executes (console.log (“2”)) to micro-task.
- The next call is to the print3 function that executes Promise and adds callback function to the micro-task to execute (setTimeout (console.log (“3”)), 100ms). The event loop decides which task has highest priority from event queue.
- Since Promise has a higher priority compared to the setTimeout(), hence the output is “2” and “4”.
- Execution then moves back to the last statement of start () function and outputs “done”.
- All the blocking statements and micro-tasks have been executed and the call stack is empty.
- Macro-task (setTimeout) executes. In Macro-task setTimeout that has a lower time delay, executes first hence the output is “3” followed by “5”.
Let's review one final example:
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
Promise.resolve().then(() => setTimeout(() => console.log(4)));
Promise.resolve().then(() => console.log(5));
setTimeout(() => console.log(6));
console.log(7);
Let's review how the micro-tasks and macro-tasks execute:
console.log (1);
// The first line executes immediately, it outputs `1`.
// Macrotask and microtask queues are empty, as of now.
setTimeout(() => console.log(2));
// `setTimeout` appends the callback to the macrotask queue.
// - macrotask queue content:
// `console.log(2) `
Promise.resolve().then(() => console.log(3));
// The callback is appended to the microtask queue.
// - microtask queue content:
// `console.log(3)`
Promise.resolve().then(() => setTimeout(() => console.log(4)));
// The callback with `setTimeout(...4)` is appended to microtasks
// - microtask queue content:
// `console.log(3); setTimeout(...4)`
Promise.resolve().then(() => console.log(5));
// The callback is appended to the microtask queue
// - microtask queue content:
// `console.log (3); setTimeout(...4); console.log (5)`
setTimeout(() => console.log(6));
// `setTimeout` appends the callback to macrotasks
// - macrotask queue content:
// `console.log (2); console.log (6)`
console.log(7);
// Outputs 7 immediately.
Output:
1
7
3
5
2
6
4
- Numbers 1 and 7 show up immediately because simple console.log calls don’t use any queues.
- Then, after the main code flow is finished, the micro-task queue runs.
• It has commands: console.log(3); setTimeout(...4); console.log (5).
• Numbers 3 and 5 show up, while setTimeout(() => console.log (4)) adds the console.log (4) call to the end of the macro task queue.
• The macro-task queue is now: console.log (2); console.log (6); console.log (4). - After the microtask queue becomes empty, the macro task queue
executes. It outputs 2, 6, and 4. Thus, we have the output: 1 7 3 5 2 6 4.
I hope this clarifies basic concepts of how setTimeout and Promise work under the hood and their prioritization.
Last but not the least, we can use web workers as an alternative to single threaded event loops to make the execution faster.
What Are Web Workers
Web Workers is a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface. Workers have their own event loop, so we can create multiple event loops to handle complex code by creating separate web workers for each
of them.
Free Up the Event Loop with Web Workers
JavaScript gives us an event loop so we can set timeouts and listeners without blocking script execution. The problem with this approach shows when you start writing CPU intensive code. The event loop can
only execute one task at a time, so if one of those operations takes 5 seconds, nothing else can run until it has finished. On top of the JavaScript engine being locked during this time, the browser will not render any UI changes.
This results in the entire browser freezing. As we move to heavier client-side applications, this limitation becomes more obvious. Web Workers are one of the new features in the HTML5 spec and allow us to essentially create multiple event loops. By running expensive operations in their own threads, the user experience stays responsive.
About Encora
Fast-growing tech companies partner with Encora to outsource product development and drive growth. Contact us to learn more about our software engineering capabilities.