DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

JavaScript Event Loop Explained: Call Stack, Microtasks, and Async Execution

JavaScript is often described as a single-threaded language, yet it can handle timers, network requests, promises, and user interactions without freezing the application. The key to understanding this behavior is the event loop, which coordinates synchronous code, asynchronous callbacks, and queued tasks.

The event loop becomes especially important when working with Promises, async/await, setTimeout, and Node.js APIs. Once you understand how the call stack, microtask queue, and task queue interact, many confusing JavaScript execution-order problems become predictable.

In this article, we will trace asynchronous JavaScript step by step using a runnable example. You will see exactly why synchronous logs execute first, why Promise callbacks can run before timers, and how the event loop keeps processing work.

How the JavaScript Event Loop Coordinates Async Execution

JavaScript starts executing synchronous statements on the call stack. A function is pushed onto the stack when it starts running and removed when it finishes, so synchronous operations follow a predictable last-in, first-out execution model.

Asynchronous operations such as timers and I/O are handled outside the call stack by the host environment. When their callbacks become ready, they are placed into appropriate queues instead of interrupting the currently running synchronous code.

Promise callbacks use the microtask queue, while timer callbacks such as setTimeout are generally scheduled as tasks. After the current synchronous stack becomes empty, JavaScript processes pending microtasks before moving on to the next task, which explains why a resolved Promise callback commonly executes before a zero-delay timer.

The following example demonstrates the complete flow with numbered console messages. It uses synchronous code, Promise callbacks, queueMicrotask, setTimeout, and async/await so you can observe how different pieces of the event loop interact in a real Node.js process.

console.log("1. Script starts");

// This function represents ordinary synchronous work.
function synchronousWork() {
  console.log("2. Synchronous function starts");
  console.log("3. Synchronous function finishes");
}

synchronousWork();

// setTimeout schedules a task for a later event-loop turn.
setTimeout(() => {
  console.log("8. setTimeout callback runs");
}, 0);

// Promise callbacks are placed into the microtask queue.
Promise.resolve().then(() => {
  console.log("5. Promise.then microtask runs");
});

// queueMicrotask also schedules work in the microtask queue.
queueMicrotask(() => {
  console.log("6. queueMicrotask callback runs");
});

// This async function executes synchronously until its first await.
async function demonstrateAsyncAwait() {
  console.log("4. async function starts");

  // await pauses this function and schedules its continuation
  // as a Promise microtask after the awaited Promise settles.
  await Promise.resolve();

  console.log("7. async function continues after await");
}

demonstrateAsyncAwait();

// This log still executes synchronously because no queued callback
// can interrupt the currently running JavaScript stack.
console.log("9. Script finishes");

// A second timer demonstrates that timers run after queued microtasks
// have had an opportunity to execute.
setTimeout(() => {
  console.log("10. Second timer callback runs");
}, 0);

// The expected high-level execution order is:
// 1. Synchronous script starts.
// 2. synchronousWork executes immediately.
// 3. Timers are registered for later execution.
// 4. Promise and queueMicrotask callbacks are queued as microtasks.
// 5. async function runs until await.
// 6. The remaining synchronous script finishes.
// 7. Microtasks are processed before timer tasks.
// 8. Timer callbacks execute afterward.

console.log("11. Main script has completed scheduling work");
Enter fullscreen mode Exit fullscreen mode

Conclusion

The event loop is the coordination mechanism that allows JavaScript to remain responsive while asynchronous work is being scheduled. The most important mental model is to separate the current call stack from queued asynchronous work and then remember that microtasks receive priority before the next task is processed.

Promises and async/await are therefore not magically creating additional JavaScript threads. Instead, they schedule continuations that the runtime executes when the current synchronous work has finished, while the surrounding environment handles operations such as timers and I/O.

Understanding this execution model makes debugging asynchronous JavaScript much easier. It also helps you write more predictable Node.js and browser applications because you can reason about exactly when callbacks become eligible to run.

Top comments (0)