Inside the JavaScript Event Loop:
The Engine Behind Asynchronous Execution

Published on: April 19th, 2025
Understanding JavaScript's event loop is crucial for mastering asynchronous programming. This blog explores the asynchronicity of JavaScript, and how it manages to stay responsive even while executing potentially blocking code.

Javascript is Single-Threaded

Javascript executes code in a single thread, line by line, in a deterministic direction.

javascript
1const printAfterDelay = (message, delay) => {
2	// simulating a long-running task
3	let iter = 0;
4	while (iter < delay) {
5		iter++;
6	}
7	console.log(message);
8};
9
10// Executes immediately
11console.log('Statement 1'); // Output: Statement 1
12// Executes after a delay
13printAfterDelay('Statement 2', 1000000000); // Output: Statement 2
14// Executes after pervious task was completed
15console.log('Statement 3'); // Output: Statement 3

Diagram Loading...

In the above example, printAfterDelay() blocks the thread. The statements after it will not execute until the function returns.

Callstack

The call stack is a data structure that keeps track of function calls. It follows the Last In First Out (LIFO) principle.

javascript
1function functionD() {
2	console.log("Function D started");
3}
4const functionC = () => {
5	console.log("Function C started");
6	functionD();
7}
8const functionB = () => {
9	console.log("Function B started");
10	functionC();
11}
12const functionA = () => {
13	console.log("Function A started");
14	functionB();
15}
16
17console.log("Hello World");
18functionA();
19console.log("Bye World");

Diagram Loading...

  • When you call functionA() it gets added to the call stack.

  • functionA() then calls functionB(), which is also added to the stack.

  • Next, functionB() calls functionC(), and functionC() is pushed onto the stack.

  • functionC() calls functionD(), which is then pushed onto the stack.

  • After functionD() finishes executing, it is removed from the stack.

  • This process repeats, with each function returning and being popped off the stack, until the stack is completely cleared.

  • Once the call stack is empty, JavaScript can move on to execute the next statements in the code

Callstacks maintain the execution context

When a function is executing on the call stack, its execution context maintains references to its lexical environment, allowing it to access variables according to its predefined lexical scope.

You can learn more about lexical scope in the following article:

Understanding Closures: Capturing Lexical Environments

Callstacks hold memory references

When a function is invoked, it gets pushed onto the call stack. If the function works with primitive values, those values are directly stored on the call stack. However, when the function deals with reference types like objects or arrays, only a reference to their location in the heap is placed on the call stack, not the actual data itself.

javascript
1// Call stack memory
2const genericFunction = (primitiveValue, referenceValue) => {
3	console.log("Primitive Value:", primitiveValue);
4	console.log("Reference Value:", referenceValue);
5};
6
7// gets copied onto the stack
8const primitiveValue = 42;
9// sits on the heap, call stack has a reference to it
10const referenceValue = { name: "John Doe" };
11
12genericFunction(primitiveValue, referenceValue);

What is Asynchronous execution?

Certain time-consuming tasks, such as network requests or timers, can be delegated to background threads. This allows JavaScript to keep running other code on the main thread without waiting for those tasks to finish. This approach is known as asynchronous execution.

javascript
1const runAfterTimeout = (callback, delay) => {
2	setTimeout(() => {
3		callback();
4	}, delay);
5};
6
7console.log('Started Script');
8runAfterTimeout(() => {
9	console.log('Timeout Finished');
10}, 1000);
11console.log('End of Script');
12
13
14// Output:
15// Started Script
16// End of Script
17// Timeout Finished

Diagram Loading...

Promise

A Promise in JavaScript is an object that represents the eventual completion (success) or failure of an asynchronous operation and its resulting value.

javascript
1const getPromiseValue = () =>
2	new Promise(resolve => {
3		console.log('Promise is being resolved...');
4		setTimeout(() => {
5			// ASYNC due to setTimeout, not the promise
6			resolve('Promise resolved successfully!');
7		}, 1000);
8	});
9
10console.log('This is a message before the promise is resolved.');
11
12getPromiseValue()
13	.then(value => {
14		// ASYNC
15		console.log(value); // Promise resolved successfully!
16	})
17	.catch(error => {
18		// ASYNC
19		console.error('Error:', error);
20	});
21
22// Runs immediately, does not wait for the promise to resolve
23console.log('This is a message after the promise is resolved.');
24
25// Output:
26//
27// Synchronous code:
28//
29// This is a message before the promise is resolved.
30// Promise is being resolved...
31// This is a message after the promise is resolved.
32//
33// Asynchronous code:
34// Promise resolved successfully!
35

.then and .catch() are methods that allow you to handle the result of the promise once it is resolved or rejected, respectively.

These methods run asynchronously, meaning they don't block the execution of the code that follows them.

new Promise((resolve, reject) => ) is a constructor that takes a callback function as an argument. This callback function is executed immediately, only the .then() and .catch() methods are executed later, when the promise is resolved or rejected.

Callback hell

Callback hell is a situation where you have multiple nested callbacks, resulting in code that is hard to read and maintain. This often happens when you have to perform multiple asynchronous operations in sequential order.

javascript
1const task2 = callback => {
2	setTimeout(() => {
3		console.log('Task 2 completed');
4		callback();
5	}, 2000);
6};
7
8const task1 = callback => {
9	setTimeout(() => {
10		console.log('Task 1 completed');
11		callback();
12	}, 1000);
13};
14
15task1(() => {
16	task2(() => {
17		console.log('All tasks completed');
18	});
19});
20console.log('Exiting...');
21
22
23// Output:
24// Exiting...
25// Task 1 completed
26// Task 2 completed
27// All tasks completed

We can cure callback hell with Promises

javascript
1const task2 = () =>
2	new Promise((resolve, reject) => {
3		setTimeout(() => {
4			console.log('Task 2 completed');
5			resolve();
6		}, 2000);
7	});
8
9const task1 = () =>
10	new Promise((resolve, reject) => {
11		setTimeout(() => {
12			console.log('Task 1 completed');
13			resolve();
14		}, 1000);
15	});
16
17task1()
18	.then(() => task2())
19	.then(() => console.log('All tasks completed'))
20	.catch(error => console.error('An error occurred:', error));
21
22console.log('Exiting...');
23
24// Output:
25// Exiting...
26// Task 1 completed
27// Task 2 completed
28// All tasks completed

Async/Await, a cure for promise hell

As you can see in the above example, we can chain multiple promises, but it can still get messy. Async/Await is a syntactic sugar over Promises that allows us to write asynchronous code in a more synchronous-looking manner. It makes the code easier to read and maintain.

javascript
1const task2 = () =>
2	new Promise((resolve, reject) => {
3		setTimeout(() => {
4			console.log('Task 2 completed');
5			resolve();
6		}, 2000);
7	});
8
9const task1 = () =>
10	new Promise((resolve, reject) => {
11		setTimeout(() => {
12			console.log('Task 1 completed');
13			resolve();
14		}, 1000);
15	});
16
17const runTasks = async () => {
18	console.log('Starting tasks...');
19	await task1();
20	await task2();
21	console.log('All tasks completed');
22}
23
24runTasks()

Dont forget to use await

Only if the function is declared as async, you can use the await keyword. The await keyword pauses the execution of the async function until the promise is resolved or rejected.

javascript
1const getPromiseValue = async () =>
2	await new Promise(resolve => {
3		resolve('Promise resolved successfully!');
4	});
5
6const main = async () => {
7	console.log('Starting main function...');
8	const value = await getPromiseValue();
9	console.log(value);
10	console.log("After promise resolved");
11}
12
13main();
14console.log('Main function completed.');
15
16// Output:
17//
18// Synchronous code
19// Starting main function...
20// Main function completed.
21//
22// Asynchronous code 
23// Promise resolved successfully!
24// After promise resolve

If you forget to add the async keyword, you will get an Promise {<pending />} response and the async function will not pause its execution.

javascript
1const getPromiseValue = async () =>
2	await new Promise(resolve => {
3		resolve('Promise resolved successfully!');
4	});
5
6const main = async () => {
7	console.log('Starting main function...');
8	const value = getPromiseValue();
9	console.log('Promise value:', value);
10	console.log("After promise resolved");
11}
12
13main();
14console.log('Main function completed.');
15
16// Output:
17//
18// Starting main function...
19// Promise value: Promise { <pending> }
20// After promise resolved
21// Main function completed.

Callback queues and their priority

Diagram Loading...

The event loop is a mechanism that allows JavaScript to perform asynchronous operations. It continuously checks the call stack and the callback queues to see if there are any tasks that need to be executed. If the call stack is empty, it will take the first task from the callback queue and push it onto the call stack for execution.

The microtask queue is prioritized over the macrotask queue. This means that if there are tasks in both queues, the event loop will first execute all the tasks in the microtask queue before moving on to the macrotask queue. This is why promises and mutation observer callbacks are executed before any other tasks in the macrotask queue.

Microtask queue

javascript
1// 1. process.nextTick (Node.js only)
2process.nextTick(() => console.log('process.nextTick microtask'));
3
4// 2. Promise callbacks
5Promise.resolve().then(() => console.log('Promise microtask'));
6
7// 3. queueMicrotask
8queueMicrotask(() => console.log('queueMicrotask microtask'));
9
10// 4. MutationObserver (browser only)
11const observer = new MutationObserver(
12	() => console.log('MutationObserver microtask'));
13observer.observe(document.body, { childList: true });
14document.body.appendChild(document.createElement('div'));
15// triggers observer
16

process.nextTick() is a Node.js always has a higher precedence than the rest of the items in the microtask queue. It is executed before any other microtask, even if it was added later.

The remaining items in the microtask queue are executed in the order they were added.

Macrotask queue

javascript
1// Timer phase, runs first
2// 1. setTimeout
3setTimeout(() => console.log('setTimeout macrotask'), 0);
4
5// 2. setInterval
6const interval = setInterval(() => {
7  console.log('setInterval macrotask');
8  clearInterval(interval);
9}, 0);
10//
11
12// 3. UI events (browser only)
13document.body.addEventListener('click',
14	() => console.log('UI event macrotask'));
15
16// 4. MessageChannel
17const channel = new MessageChannel();
18channel.port1.onmessage = () => console.log('MessageChannel macrotask');
19channel.port2.postMessage('ping');
20
21// 5. setImmediate (Node.js only)
22setImmediate(() => console.log('setImmediate macrotask'));
23
24// 6. I/O events (Node.js, browser)
25const fs = require('fs');
26fs.readFile(__filename, () => console.log('I/O macrotask'));

In browsers, the concept of phases is less formalized. However in both browsers and Node.js, the timer phase is executed first.

Conclusion

Understanding the event loop is crucial for mastering asynchronous programming in JavaScript. It allows you to write non-blocking code, making your applications more responsive and efficient.

I hope to see you in the next article.

In this article