Jagadhiswaran Devaraj

Apr 07, 2025 • 5 min read

Understanding Async/Await, Promises, and the JavaScript Event Loop (with Real Examples)

A practical deep dive into how JavaScript handles asynchronous operations using the call stack, microtask queue, and Web APIs with Next.js examples and performance breakdowns.

Understanding Async/Await, Promises, and the JavaScript Event Loop (with Real Examples)

Writing async/await in JavaScript often feels intuitive, but understanding what happens under the hood is critical for building robust, scalable applications. This article explores how JavaScript handles asynchronous code through Promises and the Event Loop, with practical examples based on a real-world Next.js setup.

The JavaScript Runtime and the Event Loop

JavaScript runs in a single-threaded environment, executing one operation at a time through its Call Stack. Despite this, it handles multiple asynchronous tasks concurrently using its runtime and the Event Loop.

Core components involved in this process:

  • Call Stack: A stack data structure that manages function calls and executes synchronous code.

  • Web APIs: Provided by the host environment (browser or Node.js), responsible for handling async tasks like fetch, timers, and event listeners.

  • Microtask Queue: A high-priority queue that holds resolved Promises and functions scheduled via queueMicrotask.

  • Callback Queue (or Task Queue): Contains lower-priority callbacks such as setTimeoutsetInterval, and event listeners.

  • Event Loop: A continuously running process that checks if the Call Stack is empty, and if so, pushes the next task from the Microtask Queue or Callback Queue onto the Call Stack.

Execution steps:

  1. Execute synchronous code from the Call Stack.

  2. Once the stack is clear, execute all Microtasks in FIFO order.

  3. Then take one task from the Callback Queue.

  4. Repeat the cycle.

This design allows non-blocking behavior and high responsiveness in JavaScript applications.


Understanding Promises in Depth

Promise is a proxy for a value that will eventually be returned (or fail) at some point in the future. It represents the eventual result of an asynchronous operation.

A Promise has three states:

  • Pending: Initial state, neither fulfilled nor rejected.

  • Fulfilled: Operation completed successfully, and a value is available.

  • Rejected: Operation failed, and a reason or error is available.

Example:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Data loaded");
  }, 1000);
});

Attach handlers using .then() and .catch():

promise
  .then(data => {
    console.log("Success:", data);
  })
  .catch(error => {
    console.error("Error:", error);
  });

Promise Execution Flow

When resolve() or reject() is called, the corresponding .then() or .catch() callbacks are pushed to the Microtask Queue. These callbacks are executed after the current Call Stack is cleared.

Unlike setTimeout and other Web API tasks, Promises always go to the Microtask Queue, which has higher priority. This ensures Promises are handled as soon as possible.

Chaining Promises

Promises support chaining through .then():

fetchData()
  .then(transformData)
  .then(sendToServer)
  .catch(handleError);

Each .then() returns a new Promise, allowing the chain to continue. Errors anywhere in the chain are passed down to the nearest .catch().

Promise Utilities

JavaScript provides several utility functions to manage multiple Promises:

  • Promise.all([...]): Waits for all Promises to resolve or rejects immediately if any fail.

  • Promise.allSettled([...]): Waits for all Promises to complete (either resolve or reject) and returns an array of status results.

  • Promise.race([...]): Resolves or rejects as soon as one Promise settles.

  • Promise.any([...]): Resolves as soon as any one Promise fulfills; rejects only if all Promises reject.

These utilities are powerful tools for concurrent and fault-tolerant execution.


Practical Example Using Next.js

Implement a small Next.js application to demonstrate different asynchronous execution strategies:

  • /api/data: A single fetch request

  • /api/multi-data: Two sequential fetches

  • /api/settled-data: Two parallel fetches using Promise.allSettled()

In the frontend component, trigger all three endpoints and log the execution times:

console.time("Single Request");
await fetch("/api/data");
console.timeEnd("Single Request");

console.time("Sequential");
await fetchData();
await fetchData();
console.timeEnd("Sequential");

console.time("Parallel");
await Promise.allSettled([fetchData(), fetchData()]);
console.timeEnd("Parallel");

Expected outcome:

  • Single request: approximately 1000ms

  • Sequential fetches: approximately 2000ms

  • Parallel fetches: approximately 1000ms (both run concurrently)


What Happens During an await fetch()?

When JavaScript encounters an await expression:

  1. The fetch() function is called, initiating an HTTP request. This is handled by the browser's Web API environment.

  2. The function in which await is used is paused at that line.

  3. Once the HTTP request resolves, the resulting Promise is marked as fulfilled.

  4. The fulfillment handler is queued in the Microtask Queue.

  5. After all current synchronous code finishes and the Call Stack is clear, the Event Loop processes tasks in the Microtask Queue.

  6. The original async function resumes execution with the resolved result.

This behavior gives the illusion of synchronous code while maintaining non-blocking execution.


Sequential vs Parallel Execution

Sequential execution:

await fetchData();
await fetchData();

Each fetch operation waits for the previous one to complete before starting. This pattern is straightforward but inefficient for independent tasks.

Parallel execution:

await Promise.allSettled([fetchData(), fetchData()]);

All fetch calls are initiated at the same time. The function waits until all of them settle (resolve or reject). This pattern is optimal for parallel, unrelated tasks.

In real-world applications, choosing between sequential and parallel execution affects both performance and error handling strategies.


Error Handling with try/catch

When a Promise rejects, its error is placed in the Microtask Queue. The Event Loop ensures that catch blocks are executed only after the Call Stack is clear:

try {
  const res = await fetch("/api/data");
} catch (err) {
  console.error("Request failed", err);
}

This approach enables structured error handling and prevents application crashes due to unhandled Promise rejections.

If Promise.all() is used, a single rejection causes the entire batch to reject. In contrast, Promise.allSettled() returns an array of status results for each Promise, allowing individual failures to be handled gracefully.


Final Thoughts

Understanding the JavaScript Event Loop is essential for writing performant, non-blocking code.

Key concepts to remember:

  • JavaScript is single-threaded, but async behavior is made possible by the Event Loop and supporting queues.

  • Promises represent future values and are executed via the Microtask Queue.

  • await is syntactic sugar over Promises and leverages the Event Loop to pause/resume execution.

  • Sequential await calls are slower when tasks are independent.

  • Promise.allSettled() enables concurrent execution with granular error reporting.

  • Proper use of try/catch ensures robust error handling in async code.

Mastering these underlying mechanics is essential for building predictable, efficient, and scalable JavaScript applications.

- Jagadhiswaran Devaraj


Video Explanation

Want to see how JavaScript handles async code behind the scenes?

Watch the short demo video below. It walks through real code examples and visually explains how the Call Stack, Web APIs, Microtask Queue, and Event Loop work together to handle async/await, Promises, and error handling—all in under a minute.


📢 Stay Connected & Dive Deep into Tech!

🚀 Follow me for hardcore technical insights on JavaScript, Full-Stack Development, AI, and Scaling Systems:

🐦 X (Twitter): jags

✍️ Read more on Medium: https://medium.com/@jwaran78

💼 Connect with me on LinkedIn: https://www.linkedin.com/in/jagadhiswaran-devaraj/

Let’s geek out over code, architecture, and all things in tech! 💡🔥

Join Jagadhiswaran on Peerlist!

Join amazing folks like Jagadhiswaran and thousands of other people in tech.

Create Profile

Join with Jagadhiswaran’s personal invite link.

0

5

0