
The event loop is one of those concepts in JavaScript that feels deceptively simple but is foundational to understanding how the language handles asynchronous operations. First off, let’s be clear: JavaScript is single-threaded, which means it can only do one thing at a time. However, it still manages to handle events and execute code in a non-blocking manner, thanks to the event loop.
At its core, the event loop is a mechanism that allows JavaScript to perform non-blocking I/O operations. It does this by placing operations on an event queue, which are then executed when the call stack is empty. This means that while your code is busy processing one task, it can still respond to other events, keeping the user experience smooth and responsive.
To illustrate how the event loop works, consider this example:
console.log("Start");
setTimeout(() => {
console.log("Timeout finished");
}, 0);
console.log("End");
When you run this code, you’ll see the following output:
Start End Timeout finished
Here’s what happens: when setTimeout is called, it’s sent to the Web APIs (which run in the background). Once the timer finishes, the callback function goes into the event queue. However, the call stack is still busy executing the console.log("End") statement, so it can’t process the timeout callback until the stack is clear.
This leads us to an important point: the event loop continues to check if the call stack is empty. If it is, it will take the first task from the event queue and push it onto the call stack for execution. This is why understanding the timing of your operations is crucial in JavaScript.
Consider this another example that might help clarify the differences between tasks and the behavior of the event loop:
console.log("First");
setTimeout(() => {
console.log("Second");
}, 100);
Promise.resolve().then(() => {
console.log("Third");
});
console.log("Fourth");
The output of this code would be:
First Fourth Third Second
What’s happening here? The Promise.resolve() is executed immediately, and its .then() method is placed on the microtask queue, which has a higher priority than the event queue. So, even though the setTimeout has a 100ms delay, the promise resolution will complete before. This demonstrates how microtasks and tasks are handled differently by the event loop.
This understanding of the event loop is vital when you are building complex applications that rely on user interactions, data fetching, or any asynchronous operations. With this knowledge, you can optimize your applications to be more efficient and responsive. There’s a lot more depth to explore regarding how the call stack, event queue, and microtasks interact with the event loop, but grasping these fundamentals sets the stage for deeper dives into performance and debugging strategies.
Next, we can look at the role of the call stack in JavaScript and how it fits into this broader picture…
ProCase 2 Pcs Screen Protector for iPad A16 2025 11th Generation 11 Inch/iPad 10th 2022 10.9 Inch, Tempered Glass Film Guard -Clear
$5.24 (as of June 2, 2026 22:39 GMT +00:00 - More infoProduct prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on [relevant Amazon Site(s), as applicable] at the time of purchase will apply to the purchase of this product.)The role of the call stack in JavaScript
The call stack is a critical component of JavaScript’s execution model. At its core, the call stack is a data structure that keeps track of function calls in your program. It operates on a last-in, first-out (LIFO) basis, meaning that the last function called is the first to be executed and removed from the stack.
When you invoke a function, a new frame is created and pushed onto the stack. This frame contains information about the function’s execution context, including local variables, the value of this, and the location to return to once the function completes. Once the function finishes executing, its frame is popped off the stack, and control returns to the calling function.
Consider this simple example:
function firstFunction() {
console.log("First Function");
secondFunction();
}
function secondFunction() {
console.log("Second Function");
}
firstFunction();
In this scenario, when firstFunction is called, it gets pushed onto the call stack. Inside firstFunction, when secondFunction is called, it too gets pushed onto the stack. The output of this code will be:
First Function Second Function
After secondFunction completes, its frame is popped off the stack, and control returns to firstFunction, which then completes and is also removed from the stack. This sequential execution is straightforward, but it becomes complex when you start introducing asynchronous calls.
Let’s explore what happens when you mix synchronous and asynchronous functions. Take a look at this example:
console.log("Start");
setTimeout(() => {
console.log("Timeout");
}, 0);
console.log("End");
When this code runs, the output will be:
Start End Timeout
Here, the setTimeout function is asynchronous. When it’s called, it’s pushed to the Web APIs, and while the timer is counting down, the call stack continues executing the next line of code. After “End” is logged, only then does the call stack check the event queue for any pending tasks and finds the setTimeout callback ready to execute.
This illustrates an important characteristic of the call stack: it can only execute one function at a time. In a scenario where a function calls another function that is also asynchronous, the stack will not wait for the asynchronous operations to complete before moving on to the next line of code. Understanding this behavior is crucial, especially when managing multiple asynchronous operations.
To further illustrate the point, consider the following example that includes a promise:
console.log("Start");
setTimeout(() => {
console.log("Timeout");
}, 0);
Promise.resolve().then(() => {
console.log("Promise resolved");
});
console.log("End");
The output will be:
Start End Promise resolved Timeout
In this case, the promise’s .then() callback is placed in the microtask queue, which has higher priority than the event queue used by setTimeout. As a result, when the call stack is clear, the event loop first processes the promise resolution before handling the timeout callback. This is a key point in understanding how the call stack interacts with asynchronous programming in JavaScript.
The behavior of the call stack directly influences the performance and responsiveness of your application. If you don’t manage your function calls and asynchronous operations effectively, you could end up with performance bottlenecks or unresponsive user interfaces. Recognizing how to structure your code with these concepts in mind will lead to better optimization and cleaner asynchronous patterns.
As we delve deeper into the event loop, we’ll explore how the event queue interacts with the call stack and the implications for your JavaScript applications…
How the event queue interacts with the event loop
So we’ve established that the call stack executes code and that asynchronous operations get handed off to Web APIs. But what happens when those asynchronous operations are finished? They don’t just magically jump back into your code. They have to wait in line, and that line is called the event queue, or more formally, the “task queue.”
Think of the event queue as a simple FIFO (First-In, First-Out) queue. When an asynchronous event occurs-like a timer from setTimeout expiring, a user clicking a button, or data arriving from a network request-its associated callback function is placed at the end of this queue. It’s a very orderly waiting room. The first callback that arrived will be the first one to be considered for execution.
The event loop’s job is to be the bouncer between this waiting room (the event queue) and the main stage (the call stack). The rule is simple: the event loop constantly checks if the call stack is empty. If it is, and only if it is, the event loop will take the first item from the event queue and push it onto the call stack, which then executes it. This single, continuous process is what allows JavaScript to feel concurrent despite being single-threaded. While your main script is running, the call stack is full. Once it’s done, the event loop gets its chance to bring in the next waiting task.
// All of these are sent to the Web APIs at roughly the same time.
setTimeout(() => { console.log('Callback A'); }, 0);
setTimeout(() => { console.log('Callback B'); }, 0);
setTimeout(() => { console.log('Callback C'); }, 0);
// Some long-running synchronous code
for (let i = 0; i < 1000000000; i++) {
// This loop blocks the main thread
}
console.log('Loop finished');
Even though the timeouts are set to 0 milliseconds, their callbacks don’t run immediately. They are placed in the event queue after their timers expire (which is almost instantly). However, the call stack is busy with the for loop. The event loop can’t do anything until that loop completes and console.log('Loop finished') is executed. Only then does the stack become empty, allowing the event loop to push Callback A, then Callback B, and then Callback C onto the stack for execution, one at a time.
Now, here’s the part that trips up even experienced developers. There isn’t just one queue. As we touched on earlier, there’s also the microtask queue, which has a higher priority. Callbacks for Promises (.then(), .catch(), .finally()) and a few other APIs like MutationObserver go into the microtask queue. The event loop has a different rule for this queue: after any single task from the regular event queue (now often called the macrotask queue) finishes, the event loop will execute *all* the callbacks currently in the microtask queue before moving on to the next macrotask. Not just one-all of them.
Let’s look at a more complex interaction to make this clear.
console.log('Start');
// Macrotask
setTimeout(() => {
console.log('Timeout');
}, 0);
// Microtask
Promise.resolve().then(() => {
console.log('Promise 1');
}).then(() => {
console.log('Promise 2');
});
console.log('End');
The execution order here is critical. First, the synchronous code runs, logging “Start” and “End”. During this, the setTimeout callback is placed in the macrotask queue, and the first .then() callback is placed in the microtask queue. Once the main script finishes, the call stack is empty. The event loop first checks the microtask queue. It finds “Promise 1″‘s callback, executes it, and that execution chains another .then(), placing “Promise 2″‘s callback in the microtask queue. The event loop sees the microtask queue is *still* not empty, so it immediately runs “Promise 2″‘s callback. Only after the microtask queue is completely drained does it check the macrotask queue and finally execute the setTimeout callback. The output is:
Start End Promise 1 Promise 2 Timeout
This priority system is not just an academic detail; it has real-world consequences for how you structure your asynchronous code, especially when you need to guarantee that one action happens immediately after another without being interrupted by less critical tasks like timers or rendering updates. Understanding this interaction between the queues and the loop is the key to mastering asynchronous JavaScript and avoiding perplexing bugs related to timing and execution order.
Practical examples of the event loop in action
Let’s move beyond theoretical examples and look at situations you’ll actually encounter. One of the most common and frustrating problems in front-end development is a frozen user interface. You click a button that’s supposed to kick off a complex calculation, and the entire page locks up. The spinner stops spinning, other buttons don’t respond. This happens because a long-running synchronous task is hogging the call stack, preventing the browser from doing anything else, including repainting the screen.
Imagine you have a button that, when clicked, processes a large dataset. The naive implementation might look like this:
const processButton = document.getElementById('process-data');
const statusDiv = document.getElementById('status');
processButton.addEventListener('click', () => {
statusDiv.textContent = 'Processing... please wait.';
// A very long, blocking operation
let result = 0;
for (let i = 0; i < 3000000000; i++) {
result += Math.sqrt(i);
}
statusDiv.textContent = Done! Result is ${result};
});
When you click this button, you will likely never see the “Processing…” message. The browser receives the click event, and its handler is pushed onto the call stack. The code sets the textContent, but the browser doesn’t get a chance to repaint the screen to show this change. Why? Because the very next thing in the script is a massive for loop that takes over the call stack for several seconds. The browser is stuck. It can’t handle other clicks, it can’t re-render the DOM, nothing. Only after the loop completes and the “Done!” message is set does the function finally end, clearing the call stack and allowing the browser to repaint the screen with the final state.
The solution is to yield to the event loop. By wrapping the heavy lifting in a setTimeout with a delay of zero, you’re not telling it to run in zero milliseconds. You’re telling it to place the callback in the macrotask queue and run it as soon as the call stack is empty. This is a profound difference.
processButton.addEventListener('click', () => {
statusDiv.textContent = 'Processing... please wait.';
// Yield to the event loop
setTimeout(() => {
let result = 0;
for (let i = 0; i < 3000000000; i++) {
result += Math.sqrt(i);
}
statusDiv.textContent = Done! Result is ${result};
}, 0);
});
Now, when the button is clicked, the event handler runs. It sets the “Processing…” text. Then it encounters setTimeout, which hands the callback function over to the Web APIs and immediately returns. The event handler function finishes and is popped off the call stack. The stack is now empty! The browser can immediately repaint the screen, and the user sees “Processing…”. Then, the event loop picks up the task from the queue (our heavy loop) and pushes it onto the stack to execute. The page will still be unresponsive *during* the calculation, but the user gets immediate feedback, which makes all the difference in user experience.
This pattern becomes even more critical when you’re dealing with a mix of macrotasks and microtasks. Consider what happens when you introduce a Promise inside a setTimeout. Which one runs first?
console.log('A');
setTimeout(() => {
console.log('B');
Promise.resolve().then(() => {
console.log('C');
});
}, 0);
Promise.resolve().then(() => {
console.log('D');
setTimeout(() => {
console.log('E');
}, 0);
}).then(() => {
console.log('F');
});
console.log('G');
Let’s trace this. First, the synchronous code runs: A and G are logged. During this, a setTimeout (for B and C) is sent to the macrotask queue, and a Promise.resolve().then() (for D and E) is sent to the microtask queue. After the main script is done, the event loop checks the microtask queue first. It finds the callback for D. It runs, logging D and queueing another macrotask (for E). The first .then() returns a promise that resolves immediately, so its chained .then() (for F) is added to the *end* of the microtask queue. The loop checks again: the microtask queue is not empty! It runs the callback for F, logging F. Now the microtask queue is empty. The event loop moves to the macrotask queue. It finds the callback for B. It runs, logging B and queueing a new microtask (for C). Remember the rule: after a macrotask, check the microtask queue. The loop finds C‘s callback and runs it, logging C. Finally, it returns to the macrotask queue, finds the last task (for E), and logs E.
The final output is: A, G, D, F, B, C, E. This predictable-yet-surprising outcome is often the source of tough bugs; what are some of the most counter-intuitive event loop behaviors you’ve had to debug?
