🟨 JavaScript Q57 / 121

How does event loop handle async operations?

AI-Powered Answer ✓ Answered

The JavaScript event loop is a fundamental mechanism that enables non-blocking asynchronous execution in a single-threaded environment. It allows operations like network requests, timers, and user interactions to be handled efficiently without freezing the main thread, ensuring a responsive user experience.

Core Concepts of the Event Loop

JavaScript is inherently single-threaded, meaning it can only execute one task at a time. To handle long-running operations (like fetching data from an API, reading files, or setting timers) without blocking the main thread, JavaScript relies on the event loop. This is a continuous process that monitors various queues and the call stack, orchestrating the execution of synchronous and asynchronous code.

Key Components

  • Call Stack: A LIFO (Last-In, First-Out) stack that stores function calls and their execution contexts. When a function is called, it's pushed onto the stack, and when it returns, it's popped off. All synchronous code executes here.
  • Heap: An unstructured memory region where objects are allocated.
  • Web APIs (Browser/Node.js Environment): These are not part of the JavaScript engine itself but are provided by the runtime environment (e.g., setTimeout, fetch, DOM events in browsers; fs.readFile, HTTP requests in Node.js). They allow the JavaScript engine to delegate asynchronous tasks.
  • Callback Queue (Macrotask Queue): A FIFO (First-In, First-Out) queue where callbacks from asynchronous Web APIs (like setTimeout, setInterval, I/O operations, UI events) are placed once their delegated operations complete.
  • Microtask Queue: A higher-priority FIFO queue specifically for callbacks from Promises (.then(), .catch(), .finally()) and queueMicrotask(). It is processed completely before the Event Loop considers the Macrotask Queue.
  • Event Loop: The mechanism that constantly checks if the Call Stack is empty. If it is, it first processes all microtasks in the Microtask Queue. Once the Microtask Queue is empty, it then takes the first task (macrotask) from the Callback Queue and pushes it onto the Call Stack for execution.

How Asynchronous Operations are Handled

When an asynchronous function (e.g., setTimeout, fetch, Promise) is encountered during synchronous execution, it's pushed onto the Call Stack. Instead of executing the async logic immediately, the JavaScript engine hands off the task to the respective Web API (or Node.js API). The async function then pops off the Call Stack, allowing the main thread to continue executing subsequent synchronous code.

Once the Web API completes its delegated task (e.g., the timer expires, the network request responds successfully), it places the associated callback function into either the Callback Queue (for macrotasks) or the Microtask Queue (for microtasks like Promise resolution handlers).

The Event Loop continuously monitors the Call Stack. When the Call Stack becomes empty (meaning all synchronous code has finished executing), the Event Loop performs a 'tick'. During a tick, it first drains the entire Microtask Queue, moving all microtasks one by one to the Call Stack for execution. Only after the Microtask Queue is completely empty does the Event Loop take the very first callback from the Callback Queue (Macrotask Queue) and push it onto the Call Stack. This cycle repeats indefinitely, ensuring that asynchronous operations are eventually handled without blocking the main thread.

Microtasks vs. Macrotasks Priority

This distinction is crucial for understanding the precise execution order of asynchronous code. Microtasks (e.g., Promise.then(), Promise.catch(), Promise.finally(), queueMicrotask()) have higher priority. After the Call Stack becomes empty, all pending microtasks are executed before the Event Loop considers processing any macrotasks from the Callback Queue. This implies that if a microtask enqueues another microtask, the newly enqueued microtask will also be executed within the same event loop tick, before the next macrotask.

javascript
console.log('Start');

setTimeout(() => {
  console.log('setTimeout 1 (Macrotask)');
  Promise.resolve().then(() => {
    console.log('Promise inside setTimeout (Microtask)');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 1 (Microtask)');
});

setTimeout(() => {
  console.log('setTimeout 2 (Macrotask)');
}, 0);

console.log('End');

Execution Flow of the Example

  • 1. Initial Synchronous Execution: console.log('Start'); is executed.
  • 2. setTimeout 1: Handed to Web APIs. Its callback is scheduled to be placed in the Macrotask Queue.
  • 3. Promise 1: Handed to Web APIs. Its .then() callback is immediately placed in the Microtask Queue.
  • 4. setTimeout 2: Handed to Web APIs. Its callback is scheduled to be placed in the Macrotask Queue (after setTimeout 1's callback).
  • 5. Initial Synchronous Execution Continues: console.log('End'); is executed.
  • 6. Call Stack Empty, Event Loop Tick 1 Starts: The Call Stack is now empty.
  • 7. Process Microtasks (Tick 1): The Event Loop drains the Microtask Queue. Promise 1's callback is moved to the Call Stack and executes. console.log('Promise 1 (Microtask)'); is printed.
  • 8. Microtasks Drained: Microtask Queue is empty for now.
  • 9. Process Macrotask 1 (Tick 1): The Event Loop takes the first item from the Macrotask Queue (setTimeout 1's callback) and moves it to the Call Stack. console.log('setTimeout 1 (Macrotask)'); is printed. Inside this callback, Promise.resolve().then() is encountered, and its callback (Promise inside setTimeout) is immediately added to the Microtask Queue.
  • 10. Call Stack Empty, Event Loop Tick 2 Starts (Implicit): After setTimeout 1's callback finishes, the Call Stack is empty.
  • 11. Process Microtasks (Tick 2): The Event Loop drains the Microtask Queue again. The Promise inside setTimeout's callback is moved to the Call Stack and executes. console.log('Promise inside setTimeout (Microtask)'); is printed.
  • 12. Microtasks Drained: Microtask Queue is empty.
  • 13. Process Macrotask 2 (Tick 2): The Event Loop takes the next item from the Macrotask Queue (setTimeout 2's callback) and moves it to the Call Stack. console.log('setTimeout 2 (Macrotask)'); is printed.
  • 14. End: All tasks processed, Event Loop continues monitoring.

The final output would be: Start End Promise 1 (Microtask) setTimeout 1 (Macrotask) Promise inside setTimeout (Microtask) setTimeout 2 (Macrotask)