Under the Hood: How JavaScript's Event Loop Powers Asynchronous Execution

JavaScript · Asynchronous Execution · Promises · Event Loop

Under the Hood: How JavaScript's Event Loop Powers Asynchronous Execution

Go beyond surface-level async. Understand how JavaScript, the browser, and the event loop collaborate to manage tasks, microtasks, and promises efficiently.

Oscar Mutwiri

Oscar Mutwiri

Software engineer

11/7/2025 · 4 min read

JavaScript is a single-threaded language, meaning it executes one piece of code at a time. It can't run multiple JS functions simultaneously. If you have something like this:

console.log("First")
console.log("Second")
console.log("Third")

It will always log First -> Second -> Third . This is however just half of the story.

JavaScript gains superpowers from the environment it is run in, enabling it to have extra capabilities when it comes to running code. An environment like a browser provides features called Web APIs, which include setTimeout, fetch, DOM events, and many more. It is good to understand that these are not part of JS but the browser, which makes them accessible to JS code. Let's look at how setTimeout is executed to understand more:

console.log("First");

setTimeout(() => {
	console.log("After 2 seconds");
}, 2000);

console.log("End");

JS logs "First" and starts a 2-second timer handled by the browser. The engine doesn't pause, it continues running then logs "End". After the timer finishes, the callback is pushed back into the call stack and "After 2 seconds" is logged. Now, let's look at the same exact code, but with a setTimeout of 0ms.

console.log("First");

setTimeout(() => {
	console.log("After 0ms");
}, 0);

console.log("End");

When we run this code, most people would expect to see "First" -> "After 0ms" -> "End" simply because 0ms means execute immediately. This is, however, not the case; in between the execution, we have things we call queues. We have 2 types of queues: task queue and microtask queue. The task queue holds callbacks from things like setTimeout or DOM events, while the microtask queue holds things like Promise reactions. When we call any function from the browser's Web API, the callback is placed into the task queue, waiting for the call stack to become empty. Then after JS finishes global code execution, the call stack becomes empty, and the callback in the task queue is now pushed into the call stack for execution. All this is possible because of something called an event loop. It keeps track of what is inside the call stack and queues by checking every now and then. Let's see how the above code would be executed now. JavaScript would log "First", then start a timer for 0 ms, complete it immediately, but throw it inside the task queue to await an empty call stack. Once global execution is finished by logging "End", leaving an empty call stack, the event loop then moves the callback from the task queue to the call stack, where it executes. "First" -> "End" -> "After 0ms"

Now, what about asynchronous functions such as fetch? To understand these functions better, we need to dive a little into promises. Promises are objects returned by the APIs as a placeholder to keep track (completion or failure) of an asynchronous task. When fetch() runs, it does two things: initiate a background network request in the browser and immediately return a promise as a placeholder for the future result.

function displayData (data) {
	console.log(data)
}

const responseData = fetch("https://dog.ceo/api/breeds/image/random");
responseData.then(displayData);

console.log("First");

When this code is run, displayData is declared in the global memory together with responseData, and fetch is then handled by the browser. When the browser receives the network request, it returns a promise and handles the request. The object or promise returned contains a future state, result, and queued reactions, all managed internally by JS.

Promise {
  [[PromiseState]]: "pending",
  [[PromiseResult]]: undefined,
  [[PromiseFulfillReactions]]: [ .then handlers go here ]
}

When the line responseData.then(displayData) is reached, the display function is inserted in the reactions. Finally, "First" is logged. The browser follows rules when it comes to executing these functions. Just like we saw in setTimeout, the callback attached to the Promise (via .then) is queued for later execution, but it goes into the microtask queue and not the task queue. What differentiates these 2 queues? When the event loop notices the call stack is empty, it checks the 2 queues, starting with microtask, and when it finds a callback function, it is pushed to the call stack for execution. This means the microtask queue is given priority. If fetch and setTimeout were used in 1 code, fetch would execute first even if the setTimeout duration was 0 ms.

After fetch returns the data, it gets assigned to the result in the object, and reactions inserted using .then, such as displayData, can execute. You will notice that, when we pass displayData to .then, it does not have any arguments. This is because the data fetched is automatically passed as a parameter to the function.