When your JavaScript code runs, it does so in a single thread, meaning it can only do one thing at a time. This execution happens on the Call Stack. Think of the Call Stack like a pile of plates: when a function is called, it's pushed onto the stack. When it finishes executing, it's popped off. All synchronous code (code that runs line by line, one after another) is processed here. If a function takes a long time to complete on the Call Stack, it blocks everything else, making your web page unresponsive.
To prevent the UI from freezing, JavaScript uses asynchronous execution for tasks that might take time, like fetching data from an API, responding to user clicks, or using timers (setTimeout). These operations aren't handled directly by the Call Stack. Instead, when you initiate an asynchronous task (e.g., calling fetch() or setTimeout()), it's handed off to browser-provided Web APIs (like the DOM API, Timer API, or HTTP API) to be processed in the background. The Call Stack remains free to continue executing other synchronous code, ensuring your application stays responsive.
Once an asynchronous task handled by the Web APIs is complete (e.g., data is fetched, the timer expires, or a user clicks), its associated callback function (the code that should run next) isn't immediately put back on the Call Stack. Instead, it's placed in a Task Queue (also known as the Callback Queue). The Event Loop is the tireless mechanism that constantly monitors two things: 1) Is the Call Stack completely empty? 2) Are there any tasks waiting in the Task Queue? If the Call Stack is empty, the Event Loop takes the first task from the Task Queue and pushes it onto the Call Stack for execution. This cycle ensures that asynchronous operations don't block the main thread and that their callbacks are executed in an orderly fashion once synchronous code has finished.
Key Takeaways
- JavaScript is single-threaded, executing code on the Call Stack one task at a time.
- Asynchronous tasks (like network requests, timers) are handled by browser Web APIs, allowing the Call Stack to remain free.
- Completed asynchronous tasks place their callbacks into a Task Queue.
- The Event Loop moves callbacks from the Task Queue to the Call Stack only when the Call Stack is empty.
- This system prevents long-running operations from freezing your web application's user interface.
Code Example
How this code works
This code example illustrates how JavaScript's Event Loop, Call Stack, and various task queues manage the execution order of synchronous and asynchronous operations. It clarifies why some actions, even with a zero-millisecond delay, don't run immediately.
Initially, console.log("1. Start of script") and console.log("2. End of script") execute directly and immediately, as they are synchronous tasks placed onto the Call Stack. While these run, setTimeout(() => {}, 0) is registered as a macrotask, and Promise.resolve().then() creates a microtask. After the main synchronous script finishes, the Event Loop checks the microtask queue first. This is a crucial distinction: microtasks, like the one from Promise.resolve().then() (message "4"), are prioritized and run entirely before any macrotasks.
Therefore, console.log("4. This is a microtask...") executes next. Only once the microtask queue is completely empty does the Event Loop move to the macrotask queue. This is why setTimeout(() => {}, 0) (message "3"), even with its 0ms delay, runs last. A common beginner's gotcha is assuming 0ms means "immediately"; instead, it signifies "as soon as the Call Stack is clear and all pending microtasks have completed."