DEV Community

Sachin Singh Patwal
Sachin Singh Patwal

Posted on

JavaScript Execution Flow: Event Loop, Call Stack, Microtasks vs Macrotasks

1. Problem Statement

JavaScript’s asynchronous behavior is frequently approached through memorizing output patterns rather than understanding the execution model. This breaks down as soon as multiple async constructs interact—Promises, timers, and async/await—leading to incorrect assumptions, unpredictable ordering, and performance issues such as UI blocking. The underlying issue is the absence of a deterministic model of how the runtime schedules and executes work.

2. Mental Model

JavaScript execution is orchestrated by a small set of well-defined components. The call stack is responsible for executing synchronous code, while memory is managed in the heap. When asynchronous operations are encountered, they are delegated to the host environment—Web APIs in the browser or libuv in Node.js—which handles their lifecycle outside the main execution thread.

Once these operations complete, their callbacks are not executed immediately. Instead, they are scheduled into queues. Broadly, these can be understood as task queues—implemented via event loop phases in Node.js for timers and I/O—and a microtask queue for Promise reactions and similar constructs. The event loop continuously coordinates between these queues and the call stack, ensuring controlled and ordered execution.

3. Execution Flow

Execution begins with the entire script running as a single task. From there, the system follows a strict scheduling algorithm:

The event loop selects one task and pushes it onto the call stack for execution.
All synchronous code within that task executes immediately.
Asynchronous operations are delegated to the host environment (Web APIs in the browser, libuv in Node.js), which schedules their callbacks once completed.
Promise reactions (then, catch, finally) and await continuations are enqueued into the microtask queue.
Once the call stack becomes empty, the event loop drains the entire microtask queue in FIFO order.
Microtasks may enqueue additional microtasks, and the queue continues draining until it is completely empty.
Only after all microtasks are processed does the event loop proceed to the next task.

This cycle repeats continuously and defines the execution model.

4. Important Clarifications

An async function does not make execution inherently asynchronous. It runs synchronously until the first await. At that point, execution pauses, and the remaining portion of the function is scheduled as a microtask. This explains why code before await behaves like standard synchronous execution, while code after it is deferred.

Promises themselves are not part of any execution queue. Only their reaction handlers—registered through then, catch, or finally—are scheduled as microtasks once the promise settles. This guarantees that Promise-based callbacks execute before any pending tasks.

Closures behave consistently across asynchronous boundaries by capturing variable bindings rather than values. As a result, asynchronous callbacks observe the most recent state of a variable at execution time, not the value at the moment the callback was created.

*5. Example *

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");
Enter fullscreen mode Exit fullscreen mode

The script begins as a single task and executes synchronously. The first statement logs “A”. The setTimeout call delegates its callback to the host environment, where it is scheduled as a future task. The resolved Promise schedules its then handler in the microtask queue. Execution then continues, logging “D”.

Once the call stack becomes empty, the event loop drains the microtask queue, logging “C”. Only after all microtasks are completed does the next task execute, logging “B”.

The final output follows the invariant that all microtasks are executed before the next task:

A
D
C
B

6. Edge Cases

Microtask starvation occurs when microtasks continuously enqueue additional microtasks, preventing the event loop from progressing to the next task. In extreme cases, this can indefinitely delay timers, I/O callbacks, and rendering in browser environments.

A common misconception is that setTimeout(..., 0) executes immediately. In reality, it schedules a task that will only execute after all pending microtasks are completed. This guarantees that Promise-based callbacks always run before timer callbacks.

The await keyword always introduces a microtask boundary, even when awaiting non-promise values. This ensures that execution after await is consistently deferred.

8. Execution Rules

The runtime follows strict invariants. Only one task executes at a time, and after each task completes, the entire microtask queue is drained before moving forward. Microtasks always have higher priority than tasks. Microtasks can enqueue additional microtasks, and execution continues until the queue is fully empty. Promise-based logic never schedules tasks, and execution order is entirely governed by these rules.

9. Self-Test

console.log(1);

setTimeout(() => console.log(2), 0);

Promise.resolve()
  .then(() => {
    console.log(3);
    return Promise.resolve();
  })
  .then(() => console.log(4));

console.log(5);
Enter fullscreen mode Exit fullscreen mode

10. Summary

JavaScript execution is driven by a single-threaded event loop coordinating between synchronous execution and queued asynchronous work. The strict priority of microtasks over macrotasks defines all observable behavior. Once this model is internalized, execution order becomes predictable and debuggable rather than memorized.

11. References
ECMAScript Specification (Jobs and Job Queues)
WHATWG HTML Event Loop Specification
Node.js libuv architecture documentation

Top comments (1)

Collapse
 
sachin_singhpatwal_cbaa0 profile image
Sachin Singh Patwal • Edited

after reading this lovely blog i got rejected one more time not recommended