JavaScript’s reputation as a single-threaded language often raises eyebrows. How can it handle asynchronous operations like network requests or timers without freezing the application?
The answer lies in its runtime architecture, which includes the call stack, Web APIs, task queues (including the microtask queue), and the event loop.
This article will discuss how JavaScript achieves this seemingly paradoxical feat. We’ll explore the interworking between the call stack, event loop, and various queues that make it all possible while maintaining its single-threaded nature.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
JavaScript’s single-threaded nature means it can only execute one task at a time. But how does it keep track of what’s running, what’s next, and where to resume after an interruption? This is where the call stack comes into play.
The call stack is a data structure that records the execution context of your program. Think of it as a to-do list for JavaScript’s engine.
Here’s how it operates:
Let’s see how the call stack works by dissecting and following the execution path of the simple script below:
function logThree() {
console.log(‘Three’);
}
Function logThreeAndFour() {
logThree(); // step 3
console.log(‘Four’); // step 4
}
console.log(‘One’); // step 1
console.log(‘Two’); // step 2
logThreeAndFour(); // step 3-4
>
In this walkthrough:
Here’s how the call stack processes the above script:
Step 1: console.log('One') is pushed onto the stack:
[main(), console.log('One')]'One', pops off the stackStep 2: console.log('Two') is pushed:
[main(), console.log('Two')]'Two', pops offStep 3: logThreeAndFour() is invoked:
[main(), logThreeAndFour()]logThreeAndFour(), logThree() is called
[main(), logThreeAndFour(), logThree()]logThree() calls console.log('Three')
[main(), logThreeAndFour(), logThree(), console.log('Three')]'Three', pops off[main(), logThreeAndFour(), logThree()] → logThree() pops offStep 4: console.log('Four') is pushed
[main(), logThreeAndFour(), console.log('Four')]'Four', pops off[main(), logThreeAndFour()] → logThreeAndFour() pops offFinally, the stack is empty, and the program exits:

Since JavaScript has only one call stack, blocking operations (e.g., CPU-heavy loops) freeze the entire application:
function longRunningTask() {
// Simulate a 3-second delay
const start = Date.now();
while (Date.now() - start < 3000) {} // Blocks the stack
console.log('Task done!');
}
longRunningTask(); // Freezes the UI for 3 seconds
console.log('This waits...'); // Executes after the loop
This limitation is why JavaScript relies on asynchronous operations (e.g., setTimeout, fetch) handled by browser APIs outside the call stack.
While the call stack manages synchronous execution, JavaScript’s true power lies in its ability to handle asynchronous operations without blocking the main thread. This is made possible by Web APIs and the task queue, which work in tandem with the event loop to offload and schedule non-blocking tasks.
Web APIs are browser-provided interfaces that handle tasks outside JavaScript’s core runtime. They include:
setTimeout, setIntervalfetch, XMLHttpRequestaddEventListener, click, scrollThese APIs allow JavaScript to delegate time-consuming operations to the browser’s multi-threaded environment, freeing the call stack to process other tasks.
Let’s break down a setTimeout example:
console.log('Start');
setTimeout(() => {
console.log('Timeout callback');
}, 1000);
console.log('End');
Here’s the execution flow of the above snippet:
console.log('Start') executes and pops offsetTimeout() registers the callback with the browser’s timer API and pops offconsole.log('End') executes and pops off() => { console.log(...) } is added to the task queueconsole.log('Timeout callback') executes:Start End Timeout callback

Note that timer delays are minimum guarantees, meaning a setTimeout(callback, 1000) callback might execute after 1,000ms, but never before. If the call stack is busy (e.g., with a long-running loop), the callback waits in the task queue.
Let’s see another example using the Geolocation API:
console.log('Requesting location...');
navigator.geolocation.getCurrentPosition(
(position) => { console.log(position); }, // Success callback
(error) => { console.error(error); } // Error callback
);
console.log('Waiting for user permission...');
In the above snippet, getCurrentPosition registers the callbacks with the browser’s geolocation API. Then the browser handles permission prompts and GPS data fetching. Once the user responds, the relevant callback joins the task queue. This allows the event loop to transfer it to the call stack when idle:
Requesting location...
Waiting for user permission...
{ coords: ... } // After user grants permission
Without Web APIs and the task queue, the call stack would freeze during network requests, timers, or user interactions.
While the task queue handles callback-based APIs like [setTimeout], JavaScript’s modern asynchronous features (promises, async/await) rely on the microtask queue. Understanding how the event loop prioritizes this queue is key to mastering JavaScript’s execution order.
The microtask queue is a dedicated queue for:
.then(), .catch(), .finally() handlersqueueMicrotask() — Explicitly adds a function to the microtask queueasync/await — A function call after await is queued as a microtaskUnlike the task queue, the microtask queue has higher priority. The event loop processes all microtasks before moving to tasks.
The event loop follows a strict sequence of workflow. It executes all tasks in the call stack, drains the microtask queue completely, renders UI updates (if any), and then processes one task from the task queue before repeating the entire process again, continuously. This ensures promise-based code runs as soon as possible, even if tasks are scheduled earlier.
Let’s see an example of microtasks vs tasks queues:
console.log('Start');
// Task (setTimeout)
setTimeout(() => console.log('Timeout'), 0);
// Microtask (Promise)
Promise.resolve().then(() => console.log('Promise'));
console.log('End');
This produces the following output:
Start End Promise Timeout

The breakdown execution follows the following sequence:
console.log('Start') executessetTimeout schedules its callback in the task queuePromise.resolve().then() schedules its callback in the microtask queueconsole.log('End') executesPromise logsTimeout logsBefore continuing, it is worth mentioning a caveat that exists with microtasks. This has to do with nested microtasks; microtasks can schedule more microtasks, potentially blocking the event loop like below:
function recursiveMicrotask() {
Promise.resolve().then(() => {
console.log('Microtask!');
recursiveMicrotask(); // Infinite loop
});
}
recursiveMicrotask();
The above script will hang as the microtask queue is never empty, and the approach to fix this issue is to use setTimeout to defer work to the task queue.
async/await and microtasksasync/await syntax is syntactic sugar for promises. Code after await is wrapped in a microtask:
async function fetchData() {
console.log('Fetching...');
const response = await fetch('/data'); // Pauses here
console.log('Data received'); // Queued as microtask
}
fetchData();
console.log('Script continues');
The output is as follows:
Fetching... Script continues Data received
JavaScript’s single-threaded model ensures simplicity but struggles with CPU-heavy tasks like image processing, and complex or large dataset calculations. These tasks can freeze the UI, creating a poor user experience. Web Workers solve this by executing scripts in separate background threads, freeing the main thread to handle the DOM and user interactions.
Workers run in an isolated environment with their own memory space. They cannot access the DOM or window object, ensuring thread safety. Communication between the main thread and workers happens via message passing, where data is copied (via structured cloning) or transferred (using Transferable objects) to avoid shared memory conflicts.
The below code block shows a sample of delegating a complex work of processing an image that’s assumed in this scenario to take a lot of computational time to complete. worker.postMessage method sends a message to the worker. It then utilizes the worker.onmessage and worker.onerror to handle the success and error of the background work.
Here’s the main thread:
// Create a worker and send data
const worker = new Worker('worker.js');
worker.postMessage({ task: 'processImage', imageData: rawPixels });
// Listen for results or errors
worker.onmessage = (event) => {
displayProcessedImage(event.data); // Handle result
};
worker.onerror = (error) => {
console.error('Worker error:', error); // Handle failures
};
In the below code snippet, we utilize the onmessage method to receive the notification to start processing the image. The rawPixel passed down can be accessed on the event object through the data field as below.
And now we see it from the worker.js viewpoint:
// Receive and process data
self.onmessage = (event) => {
const processedData = heavyComputation(event.data.imageData);
self.postMessage(processedData); // Return result
};
Workers operate in a separate global scope, hence the use of self. Use Transferable objects (e.g., ArrayBuffer) for large data to avoid costly copying, and spawning too many workers can bloat memory; reuse them for recurring tasks.
JavaScript’s asynchronous prowess lies in its elegant orchestration of the call stack, Web APIs, and the event loop—a system that enables non-blocking execution despite its single-threaded nature. By leveraging the task queue for callback-based operations and prioritizing the microtask queue for promises, JavaScript ensures efficient handling of asynchronous workflows.
By mastering these concepts, you’ll write code that’s not just functional but predictable and performant, whether you’re handling user interactions, fetching data, or optimizing rendering.
Experiment with DevTools, embrace asynchronous patterns, and let JavaScript’s event loop work for you—not against you.

line-clamp to trim lines of textMaster the CSS line-clamp property. Learn how to truncate text lines, ensure cross-browser compatibility, and avoid hidden UX pitfalls when designing modern web layouts.

Discover seven custom React Hooks that will simplify your web development process and make you a faster, better, more efficient developer.

Promise.all still relevant in 2025?In 2025, async JavaScript looks very different. With tools like Promise.any, Promise.allSettled, and Array.fromAsync, many developers wonder if Promise.all is still worth it. The short answer is yes — but only if you know when and why to use it.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the October 29th issue.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now