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.
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
, setInterval
fetch
, XMLHttpRequest
addEventListener
, click
, scroll
These 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.
Would you be interested in joining LogRocket's developer community?
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 nowArrow functions have a simpler syntax than standard functions, and there are some important differences in how they behave.
null
, undefined
, or empty values in JavaScriptIn most languages, we only have to cater to null. But in JavaScript, we have to cater to both null and undefined. How do we do that?
Discover how the MERN stack (MongoDB, Express.js, React, Node.js) enables developers to build dynamic, performant, modern websites and apps.
Use parallel computing in Node.js with worker threads to optimize performance, handle CPU-intensive tasks, and utilize multi-core processors.