Seongyeol Yi

JavaScript is single-threaded — so how does async work?

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).

Threads

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.

Browser Threads

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.

00:00:00

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

Async

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.

00:00:00

window.alert is a synchronous function. While the alert is shown, the timer stops.

00:00:00

Note: Depending on browser policies, the timer may continue in certain situations like tab switching.

Event Loop

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.

Event-Driven Programming

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.

Call Stack and Event Loop

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.

Run-to-Completion

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.

Types of Queues

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.”

Microtask Queue

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.

Task Queue vs Microtask Queue

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

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:

  1. Complete all work on the call stack
  2. Fully drain the microtask queue (Promise, queueMicrotask, etc.)
  3. Determine whether DOM rendering is needed (browser decides)
  4. If DOM rendering is needed:
    • Execute requestAnimationFrame callbacks
    • Then render

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.

References

The videos below are incredibly helpful.

More on Frontend

View all posts