Inside the JavaScript Event Loop:
The Engine Behind Asynchronous Execution

Published on: April 19, 2025

No title

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
const printAfterDelay = (message, delay) => {
	// simulating a long-running task
	let iter = 0;
	while (iter < delay) {
		iter++;
	}
	console.log(message);
};

// Executes immediately
console.log('Statement 1'); // Output: Statement 1
// Executes after a delay
printAfterDelay('Statement 2', 1000000000); // Output: Statement 2
// Executes after pervious task was completed
console.log('Statement 3'); // Output: Statement 3
mermaid
flowchart TD
	classDef green fill:#ccffcc,stroke:#000,stroke-width:2px,color:#000000;
	classDef red fill:#ffcccc,stroke:#000,stroke-width:2px,color:#000000;
	classDef blue fill:#ccccff,stroke:#000,stroke-width:2px,color:#000000;
	classDef orange fill:#ffcc99,stroke:#000,stroke-width:2px,color:#000000;
	classDef yellow fill:#ffff99,stroke:#000,stroke-width:2px,color:#000000;

	subgraph "Main Thread"
		statement_1[console.log] --> statement_2[printAfterDelay]:::red --> statement_3[console.log]
	end

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
function functionD() {
	console.log("Function D started");
}
const functionC = () => {
	console.log("Function C started");
	functionD();
}
const functionB = () => {
	console.log("Function B started");
	functionC();
}
const functionA = () => {
	console.log("Function A started");
	functionB();
}

console.log("Hello World");
functionA();
console.log("Bye World");
mermaid
flowchart TD
    classDef green fill:#ccffcc,stroke:#000,stroke-width:2px,color:#000000;
    classDef red fill:#ffcccc,stroke:#000,stroke-width:2px,color:#000000;
    classDef blue fill:#ccccff,stroke:#000,stroke-width:2px,color:#000000;
    classDef orange fill:#ffcc99,stroke:#000,stroke-width:2px,color:#000000;
    classDef yellow fill:#ffff99,stroke:#000,stroke-width:2px,color:#000000;

    js_execution_start([JS execution starts]) --> hello_world([Hello World])
    hello_world --> |"JS offloads function calls
to the call stack"| functionA_entry[functionA called]:::green

    %% Full stack: functionA -> functionB -> functionC -> functionD
    subgraph "Call Stack Snapshot 1: All functions active"
        functionD1[functionD]:::red
        functionC1[functionC]:::orange
        functionB1[functionB]:::yellow
        functionA1[functionA]:::green
        functionD1 --> functionC1 --> functionB1 --> functionA1
    end

    functionA_entry -.-> functionD1

    %% After functionD returns
    subgraph "Call Stack Snapshot 2: functionD returned"
        functionC2[functionC]:::orange
        functionB2[functionB]:::yellow
        functionA2[functionA]:::green
        functionC2 --> functionB2 --> functionA2
    end

    functionD1 -.-> |"functionD returns"| functionC2

    %% After functionC returns
    subgraph "Call Stack Snapshot 3: functionC returned"
        functionB3[functionB]:::yellow
        functionA3[functionA]:::green
        functionB3 --> functionA3
    end

    functionC2 -.-> |"functionC returns"| functionB3

    %% After functionB returns
    subgraph "Call Stack Snapshot 4: functionB returned"
        functionA4[functionA]:::green
    end

    functionB3 -.-> |"functionB returns"| functionA4

    %% After functionA returns
    functionA4 -.-> |"functionA returns
Call stack is empty"| bye_world([Bye World])

    %% Annotations
    js_execution_start:::blue
    hello_world:::blue
    bye_world:::blue
  • 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
// Call stack memory
const genericFunction = (primitiveValue, referenceValue) => {
	console.log("Primitive Value:", primitiveValue);
	console.log("Reference Value:", referenceValue);
};

// gets copied onto the stack
const primitiveValue = 42;
// sits on the heap, call stack has a reference to it
const referenceValue = { name: "John Doe" };

genericFunction(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
const runAfterTimeout = (callback, delay) => {
	setTimeout(() => {
		callback();
	}, delay);
};

console.log('Started Script');
runAfterTimeout(() => {
	console.log('Timeout Finished');
}, 1000);
console.log('End of Script');


// Output:
// Started Script
// End of Script
// Timeout Finished
mermaid
sequenceDiagram

	participant MainThread
	participant Background as Background Thread

	
	Note over MainThread: Started Script

	MainThread->>Background: runAfterTimeout(cb, delay)
	Background-->>Background: Runs timer, waits for delay
	Note over MainThread: End of Script
	Background-->> MainThread: Timer expires
	Note over MainThread: callback function is executed

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
const getPromiseValue = () =>
	new Promise(resolve => {
		console.log('Promise is being resolved...');
		setTimeout(() => {
			// ASYNC due to setTimeout, not the promise
			resolve('Promise resolved successfully!');
		}, 1000);
	});

console.log('This is a message before the promise is resolved.');

getPromiseValue()
	.then(value => {
		// ASYNC
		console.log(value); // Promise resolved successfully!
	})
	.catch(error => {
		// ASYNC
		console.error('Error:', error);
	});

// Runs immediately, does not wait for the promise to resolve
console.log('This is a message after the promise is resolved.');

// Output:
//
// Synchronous code:
//
// This is a message before the promise is resolved.
// Promise is being resolved...
// This is a message after the promise is resolved.
//
// Asynchronous code:
// Promise resolved successfully!

.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
const task2 = callback => {
	setTimeout(() => {
		console.log('Task 2 completed');
		callback();
	}, 2000);
};

const task1 = callback => {
	setTimeout(() => {
		console.log('Task 1 completed');
		callback();
	}, 1000);
};

task1(() => {
	task2(() => {
		console.log('All tasks completed');
	});
});
console.log('Exiting...');


// Output:
// Exiting...
// Task 1 completed
// Task 2 completed
// All tasks completed

We can cure callback hell with Promises

javascript
const task2 = () =>
	new Promise((resolve, reject) => {
		setTimeout(() => {
			console.log('Task 2 completed');
			resolve();
		}, 2000);
	});

const task1 = () =>
	new Promise((resolve, reject) => {
		setTimeout(() => {
			console.log('Task 1 completed');
			resolve();
		}, 1000);
	});

task1()
	.then(() => task2())
	.then(() => console.log('All tasks completed'))
	.catch(error => console.error('An error occurred:', error));

console.log('Exiting...');

// Output:
// Exiting...
// Task 1 completed
// Task 2 completed
// 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
const task2 = () =>
	new Promise((resolve, reject) => {
		setTimeout(() => {
			console.log('Task 2 completed');
			resolve();
		}, 2000);
	});

const task1 = () =>
	new Promise((resolve, reject) => {
		setTimeout(() => {
			console.log('Task 1 completed');
			resolve();
		}, 1000);
	});

const runTasks = async () => {
	console.log('Starting tasks...');
	await task1();
	await task2();
	console.log('All tasks completed');
}

runTasks()

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
const getPromiseValue = async () =>
	await new Promise(resolve => {
		resolve('Promise resolved successfully!');
	});

const main = async () => {
	console.log('Starting main function...');
	const value = await getPromiseValue();
	console.log(value);
	console.log("After promise resolved");
}

main();
console.log('Main function completed.');

// Output:
//
// Synchronous code
// Starting main function...
// Main function completed.
//
// Asynchronous code 
// Promise resolved successfully!
// 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
const getPromiseValue = async () =>
	await new Promise(resolve => {
		resolve('Promise resolved successfully!');
	});

const main = async () => {
	console.log('Starting main function...');
	const value = getPromiseValue();
	console.log('Promise value:', value);
	console.log("After promise resolved");
}

main();
console.log('Main function completed.');

// Output:
//
// Starting main function...
// Promise value: Promise { <pending> }
// After promise resolved
// Main function completed.

Callback queues and their priority

mermaid
flowchart TD
    classDef green fill:#ccffcc,stroke:#000,stroke-width:2px,color:#000000;
    classDef red fill:#ffcccc,stroke:#000,stroke-width:2px,color:#000000;
    classDef blue fill:#ccccff,stroke:#000,stroke-width:2px,color:#000000;
    classDef orange fill:#ffcc99,stroke:#000,stroke-width:2px,color:#000000;
    classDef yellow fill:#ffff99,stroke:#000,stroke-width:2px,color:#000000;
    classDef gray fill:#f0f0f0,stroke:#000,stroke-width:2px,color:#000000;

    %% Main thread and call stack
    main_thread[Main Thread]:::blue
    call_stack[Call Stack]:::yellow
    heap[(Heap)]:::gray

    %% Web APIs
    web_apis["Web APIs<br/>(Timers, DOM, Fetch, etc.)<br />(Promises.)"]:::orange

    %% Event Loop and Queues
    subgraph "Event Loop System"
        event_loop(((Event Loop))):::red
        micro_task_queue[[Microtask Queue]]:::green
        macro_task_queue[[Macrotask Queue]]:::green
    end

    %% Flow of execution
    main_thread --> call_stack
    call_stack -- "Async tasks (e.g., setTimeout, fetch)" --> web_apis
    web_apis -- "Callback ready" --> micro_task_queue
    web_apis -- "Callback ready" --> macro_task_queue

    micro_task_queue -- "Microtasks first" --> event_loop
    macro_task_queue -- "After microtasks" --> event_loop
    event_loop --> call_stack

    heap --> call_stack

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. process.nextTick (Node.js only)
process.nextTick(() => console.log('process.nextTick microtask'));

// 2. Promise callbacks
Promise.resolve().then(() => console.log('Promise microtask'));

// 3. queueMicrotask
queueMicrotask(() => console.log('queueMicrotask microtask'));

// 4. MutationObserver (browser only)
const observer = new MutationObserver(
	() => console.log('MutationObserver microtask'));
observer.observe(document.body, { childList: true });
document.body.appendChild(document.createElement('div'));
// triggers observer

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
// Timer phase, runs first
// 1. setTimeout
setTimeout(() => console.log('setTimeout macrotask'), 0);

// 2. setInterval
const interval = setInterval(() => {
  console.log('setInterval macrotask');
  clearInterval(interval);
}, 0);
//

// 3. UI events (browser only)
document.body.addEventListener('click',
	() => console.log('UI event macrotask'));

// 4. MessageChannel
const channel = new MessageChannel();
channel.port1.onmessage = () => console.log('MessageChannel macrotask');
channel.port2.postMessage('ping');

// 5. setImmediate (Node.js only)
setImmediate(() => console.log('setImmediate macrotask'));

// 6. I/O events (Node.js, browser)
const fs = require('fs');
fs.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.