Let’s find the answer to this question:
Q. JavaScript is a single-threaded language, so how is async processing possible? Please explain focusing on the event loop, call stack, and task queue (including microtasks).
Q. JavaScript is a single-threaded language, so how is async processing possible? Please explain focusing on the event loop, call stack, and task queue (including microtasks).
What is a thread?
A thread is a unit of execution within a program. Wikipedia
“Unit of execution” is quite abstract, so let’s look at some code.
import threading
import time
def worker(name):
for _ in range(10):
print(name, end="")
time.sleep(0.01)
# Create two threads from the main program (process)
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
t1.start()
t2.start()
# Main thread waits for both threads to finish
t1.join()
t2.join()
# Example output: ABBABABABAABABBAABBA
# Output varies each run
You can see the main thread creating two threads, each doing work independently from the main thread. To put it metaphorically, a normal program’s current execution point can be tracked with a single finger, but once threads are created, you need multiple fingers.
So when do threads come up in frontend development?
The main thread is where a browser processes user events and paints. By default, the browser uses a single thread to run all the JavaScript in your page, as well as to perform layout, reflows, and garbage collection. This means that long-running JavaScript functions can block the thread, leading to an unresponsive page and a bad user experience. MDN
Let’s see it in action. The JavaScript code below runs for 3 seconds.
const now = Date.now();
while (Date.now() - now < 3000) {
// Wait for 3 seconds
}
Click the button below to run JavaScript for 3 seconds, keeping the main thread busy. Notice how the clock stops, and features like text selection and button clicks freeze.
Interestingly, scrolling still works. Here’s why:
Over the years, browser vendors have recognized that offloading work to background threads can yield enormous improvements to smoothness and responsiveness. Scrolling, being so important to the core user experience of every browser, was quickly identified as a ripe target for such optimizations. Nowadays, every major browser engine (Blink, EdgeHTML, Gecko, WebKit) supports off-main-thread scrolling to one degree or another (with Firefox being the most recent member of the club, as of Firefox 46). stackoverflow
Q. JavaScript is a single-threaded language, so how is async processing possible? Please explain focusing on the event loop, call stack, and task queue (including microtasks).
Asynchrony in programming refers to events that occur independently of the main program flow, and methods for handling such events.
In web environments, network requests, timers, and user input are typical async
operations. They’re processed in the background without blocking the main
thread. setTimeout is a classic example.
Click the button below — the clock keeps ticking and the button re-enables after 3 seconds. Notice how the 3-second wait happens independently of the main thread.
window.alert is a synchronous function. While the alert is shown, the timer
stops.
Note: Depending on browser policies, the timer may continue in certain situations like tab switching.
If JavaScript runs on a single thread, it seems like you shouldn’t be able to execute other code after calling setTimeout. While setTimeout “waits for 3 seconds,” nothing else should be able to run. But as we saw in the async button example above, the clock component updates fine during the 3-second wait.
Here’s the key insight:
The JavaScript engine is single-threaded, but the browser is multi-threaded.
Since that’s confusing, let’s clarify what the JavaScript engine is and how it relates to the browser.
The JavaScript engine (e.g., V8, SpiderMonkey) is responsible for executing JavaScript code. When people say JavaScript is single-threaded, they mean the engine can only process one task at a time.
The browser, on the other hand, is divided into multiple threads/processes.
Networking, timers, events, etc. are managed by Web APIs outside the JS engine.
The JavaScript engine itself doesn’t implement async functions. Functions like
setTimeout and fetch are defined in browser-provided Web APIs, implemented
outside the engine. In other words, the JavaScript engine only executes code —
the async work itself is handled by the browser’s other threads.
So how do the browser’s multi-threaded operations communicate with the single-threaded JavaScript engine? The event loop bridges the two. The event loop is implemented in the browser, and when a background async task completes, it moves the resulting callback function to the JavaScript engine’s call stack.
To properly understand the event loop, a shift in perspective is needed.
Programs we typically write in school follow a clear sequence: start, work, end. The script runs, finishes all its work, and the program terminates.
But JavaScript in the browser is different. Even after a script finishes executing, user-generated events like clicks and scrolls, network events, timer events, and more keep firing, and registered callbacks execute. This paradigm is called event-driven programming.
So when do these callback functions run? When the call stack is empty.
The call stack is a stack structure that records the order of JavaScript function calls. Functions are pushed onto the stack when called and popped off when they finish executing.
Let’s see how the call stack changes during actual code execution. You can check current call stack information using Error.stack.
function third() {
console.log(new Error().stack);
}
function second() {
third();
}
function first() {
second();
}
first();
// Error
// at third (<anonymous>:2:15)
// at second (<anonymous>:6:3)
// at first (<anonymous>:10:3)
// at <anonymous>:13:1
When this code runs, first calls second, and second calls third, so the call stack fills up in the order first → second → third.
An empty call stack means there’s no JavaScript code currently executing. The event loop continuously checks whether the call stack is empty — this is the critical signal that async work can be processed.
We said the event loop only runs new callbacks when the call stack is empty. This connects to JavaScript’s run-to-completion model. Run-to-completion means that the currently executing function will complete entirely before control passes to any other code. Once a function is on the call stack, it runs to the end without being interrupted.
In multi-threaded languages like C or Java, another thread can intervene mid-execution and change variables or object state. Consider this C code:
#include <pthread.h>
#include <stdio.h>
int shared_counter = 0;
void* increment_function(void* arg) {
for (int i = 0; i < 100000; i++) {
// Another thread can intervene here
shared_counter++;
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, increment_function, NULL);
pthread_create(&thread2, NULL, increment_function, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Final counter: %d\n", shared_counter);
// Expected: 200000, Actual: unpredictable (e.g., 156789)
return 0;
}
In the code above, shared_counter++ actually involves three steps — “read
value from memory → increment by 1 → write back to memory” — and another thread
can intervene during this process, producing unexpected results.
But in JavaScript, no other callback runs until the current function finishes. This property makes code like the following work without issues:
document.body.appendChild(element);
element.style.display = "none";
In a multi-threaded environment, the rendering thread could intervene after
appendChild, briefly showing the element to the user before it disappears. But
in JavaScript, rendering only happens after both lines execute, so the user
never sees the element. This property improves the predictability of DOM
manipulation.
The event loop has multiple queues, each with different priorities and roles. The most fundamental is the task queue (also called the macrotask queue).
In event-driven programming, callbacks to be executed as results of async
operations are stored in the task queue. The task queue holds callbacks from
setTimeout, setInterval, DOM events, and more.
Let’s look at task queue behavior with a simple example.
console.log("Start");
setTimeout(() => {
console.log("First timer");
}, 0);
setTimeout(() => {
console.log("Second timer");
}, 0);
console.log("End");
// Start
// End
// First timer
// Second timer
Why this order? First, console.log('Start') and console.log('End') execute
immediately on the call stack, while setTimeout callbacks are added to the task
queue. Only after ‘Start’ and ‘End’ are printed and the call stack is
completely empty does the event loop pull callbacks from the task queue one by
one. After each callback completes, it performs rendering if needed, then checks
for more callbacks.
This implies that setTimeout doesn’t guarantee execution at exactly the
specified time. Consider this example:
console.log("Start");
setTimeout(() => {
console.log("100ms timer");
}, 100);
// Keep main thread busy for 5 seconds
const start = Date.now();
while (Date.now() - start < 5000) {
// Wait 5 seconds
}
console.log("End");
// Output:
// Start
// (after ~5s) End
// (after ~5s, not 100ms!) 100ms timer
The setTimeout should fire after 100ms, but since the while loop occupies the call stack for 5 seconds, it actually runs after 5 seconds. This shows that setTimeout guarantees a “minimum delay,” not a “precise execution time.”
Early JavaScript only had the macrotask queue. But when ES6 introduced Promises and features like Mutation Observer (for detecting DOM changes) were needed, a problem emerged.
These features share a requirement: they must run immediately after the current task finishes. For example, Mutation Observer needs to detect the moment the DOM changes. If its callback went into the macrotask queue, rendering could happen before the next task — meaning the DOM change would already be painted on screen before the callback fires, making “immediate detection after change” impossible.
So a new queue was created: the microtask queue. Tasks in the microtask queue always run after the call stack empties and before the screen is painted. This allows DOM changes and Promise results to be processed “without delay.”
The microtask queue holds callbacks from Promise.then(), Promise.catch(),
Promise.finally(), async/await, queueMicrotask(), etc. After each task
completes, the browser drains the entire microtask queue, then proceeds to
rendering and the next task.
console.log("Start");
setTimeout(() => {
console.log("setTimeout (task queue)");
}, 0);
Promise.resolve().then(() => {
console.log("Promise.then (microtask queue)");
});
console.log("End");
// Output:
// Start
// End
// Promise.then (microtask queue)
// setTimeout (task queue)
Both have 0ms delay, but Promise runs first. This is because after the call stack empties, the event loop checks the microtask queue first, processes all callbacks there, and only then moves on to other stages (rendering, task queue, etc.).
What’s special about the microtask queue is that the event loop won’t advance to the next stage until it’s completely drained. This means Promise chains and consecutive microtasks always take priority over rendering and other work.
Let’s look at an example that illustrates the difference between microtasks and the task queue.
// Infinite microtask loop
function createMicrotasks() {
queueMicrotask(() => {
console.log("Microtask executed");
createMicrotasks(); // Keep creating new microtasks
});
}
createMicrotasks();
// Result: Microtask queue never empties, rendering completely blocked
In contrast, an infinite loop using setTimeout allows rendering between each task:
// Infinite task queue loop
function createTasks() {
setTimeout(() => {
console.log("Task executed");
createTasks(); // Keep creating new tasks
}, 0);
}
createTasks();
// Result: Rendering happens between each task
This difference exists because the event loop only proceeds to rendering after fully draining the microtask queue.
requestAnimationFrame, used for smooth animations, runs in a special slot — neither the task queue nor the microtask queue.
The event loop processes work in this order:
The key point is that requestAnimationFrame runs right before the actual DOM render. Since it’s only called when the browser decides to repaint, it prevents unnecessary computation and synchronizes perfectly with screen updates.
Animations using setTimeout can fall out of sync with the screen’s refresh rate. As we saw above, setTimeout doesn’t guarantee execution at the specified delay, and as callbacks drift slightly, some frames get skipped and the animation appears janky.
requestAnimationFrame, on the other hand, synchronizes perfectly with the browser’s repaint cycle.
The videos below are incredibly helpful.
Is there a reason to use React beyond job hunting?
PromiseLike in the wild, found in Supabase