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.
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.
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 setTimeout
, setInterval
, 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:
Execute synchronous code from the Call Stack.
Once the stack is clear, execute all Microtasks in FIFO order.
Then take one task from the Callback Queue.
Repeat the cycle.
This design allows non-blocking behavior and high responsiveness in JavaScript applications.
A 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);
});
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.
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()
.
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.
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)
await fetch()
?When JavaScript encounters an await
expression:
The fetch()
function is called, initiating an HTTP request. This is handled by the browser's Web API environment.
The function in which await
is used is paused at that line.
Once the HTTP request resolves, the resulting Promise is marked as fulfilled.
The fulfillment handler is queued in the Microtask Queue.
After all current synchronous code finishes and the Call Stack is clear, the Event Loop processes tasks in the Microtask Queue.
The original async function resumes execution with the resolved result.
This behavior gives the illusion of synchronous code while maintaining non-blocking 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.
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.
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
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 ProfileJoin with Jagadhiswaran’s personal invite link.
0
5
0