Javascript Event Loop


Why Your JavaScript Runs Out of Order: The Event Loop Explained
If you're working with JavaScript, you've likely encountered a situation like this. You have the following code:
setTimeout(() => console.log("Timeout!"), 0);
Promise.resolve().then(() => console.log("Promise!"));
console.log("End");
Logically, you might expect the output to be End
, Timeout!
, Promise!
. However, when you run it, the console shows:
End
Promise!
Timeout!
This isn't a bug; it's a fundamental part of how JavaScript handles asynchronous operations. While most of your code runs top-to-bottom, asynchronous tasks follow a different set of rules. This behavior is managed by a system involving the Call Stack, Web APIs, two distinct Queues, and the Event Loop.
Let's break down how this system works:
1. The Global Execution Context and The Call Stack
When you run a JavaScript file, the engine first creates a Global Execution Context (GEC). This is the base environment where your code runs. The process has two phases:
Creation Phase: The engine quickly scans the code for all variable and function declarations and allocates memory for them. This process is often called hoisting.
Execution Phase: The engine runs your code line by line.
To manage this execution, JavaScript uses a Call Stack. You can think of it as a list of tasks to be done. When a function is called, it's added (pushed) to the top of the stack. When it finishes, it's removed (popped) from the stack. The engine is always working on the task at the very top of the stack.
2. Web APIs
When the JavaScript engine encounters an asynchronous operation like setTimeout
, a fetch
request, or a DOM event listener, it doesn’t handle it directly. Waiting for these tasks to complete would block all other code from running, freezing the user interface.
Instead, the engine offloads these tasks to Web APIs provided by the browser (or the Node.js runtime). The browser can handle things like timers or network requests in the background. The JavaScript engine is then free to continue executing the rest of your synchronous code.
Once the Web API finishes its work (e.g., the timer runs out), it doesn't just interrupt your code. It places the callback function associated with the task into a queue.
3. The Task Queue and The Microtask Queue
This is a critical part of the process. There are two primary queues where callbacks wait for their turn to run:
Task Queue (or "Macrotask Queue"): This is where callbacks from older APIs like
setTimeout
,setInterval
, and DOM events are placedMicrotask Queue: This is a newer, higher-priority queue. It's used for callbacks from modern asynchronous features, most notably Promises (e.g.,
.then()
,.catch()
,.finally()
) andasync/await
The key rule to remember is that the Microtask Queue always has priority. The Event Loop will always process every task in the Microtask Queue before it considers processing a single task from the Task Queue.
4. The Event Loop
The Event Loop has one simple, continuous job: to monitor the Call Stack and the queues. It follows this cycle:
Check if the Call Stack is empty
If it is, check the Microtask Queue. If there are tasks waiting, take the first one, push it onto the Call Stack, and let it run. The loop will continue doing this until the Microtask Queue is completely empty
Only when the Call Stack and the Microtask Queue are both empty will the Event Loop check the Task Queue. If a task is waiting, it will take the oldest one, push it onto the Call Stack, and let it run
This cycle repeats, ensuring tasks are executed in the correct order of priority.
5. Putting It All Together
Let's revisit our original code and trace its execution with this system in mind.
console.log("Global start");
setTimeout(() => console.log("setTimeout callback"), 0);
Promise.resolve().then(() => console.log("Promise callback"));
function greet() {
console.log("Hello from greet!");
}
greet();
console.log("Global end");
Here's a detailed breakdown of the execution flow:
JS engine goes through the whole code, allocate memory for
greet()
Push the Global execution context on the stack
console.log("Global start")
is executed. Output:Global start
setTimeout
is encountered. The engine hands it off to the Web API. The timer starts (for 0ms)Promise.resolve().then(...)
is encountered. The promise resolves immediately, and its callback is placed in the Microtask QueueThe
greet()
function is called and executed. Output:Hello from greet!
console.log("Global end")
is executed. Output:Global end
The main script has now finished. The synchronous code is done, and the Call Stack is empty
The Event Loop checks the Microtask Queue. It finds the promise callback, moves it to the Call Stack, and executes it. Output:
Promise callback
The Event Loop checks the Microtask Queue again. It's empty. Now it checks the Task Queue. By this time, the 0ms timer has completed, and the Web API has placed the
setTimeout
callback in the Task Queue. The Event Loop moves it to the Call Stack and executes it. Output:setTimeout callback
.
This explains the final output:
Global start
Hello from greet!
Global end
Promise callback
setTimeout callback
A Practical Example
This same logic applies to everyday tasks like fetching data from an API.
console.log("Requesting user data...");
fetch('https://api.example.com/users/1')
.then(response => response.json())
.then(user => console.log("User data:", user));
console.log("Doing other tasks while waiting...");
The first
console.log
runsfetch
is called and handed off to the browser's Web API to manage the network request in the backgroundThe final
console.log
runs immediately, without waiting for the fetch to completeWhen the network request succeeds, its
.then()
callback is placed in the Microtask Queue, giving it priority to run as soon as the main thread is free
Understanding this sequence synchronous code first, then microtasks, then tasks is key to understanding JavaScript better.
Subscribe to my newsletter
Read articles from Mohammed owais directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
