JavaScript
May 22, 2026

Understanding the Event Loop in JavaScript: How Asynchronous Magic Really Works

Demystify the JavaScript event loop, call stack, task queue, and microtask queue. Learn how asynchronous code executes behind the scenes with clear visuals and practical examples.

S
Super Admin
22 views
0
2.5

The JavaScript Event Loop: Understanding How Your Code Really Runs

JavaScript runs on a single thread, yet it manages asynchronous operations without breaking a sweat. The secret? The event loop. In this guide, you will walk through how the call stack, task queue, and microtask queue all fit together - with concrete examples you can follow line by line.

What Single-Threaded Really Means in JavaScript

Saying JavaScript is single-threaded means it has exactly one call stack - it can only execute one piece of code at a time. This raises an obvious question: if that's true, how do network requests and timers work without locking everything up?

The real answer: JavaScript itself is single-threaded, but your browser (or Node.js) provides Web APIs that do the heavy lifting in separate threads. JavaScript hands off the work, and those APIs notify JavaScript when they're finished.

Here's a useful way to picture it: think of a head chef running a kitchen solo. They can only cook one dish at a time personally, but they assign prep work to kitchen helpers - chopping, boiling, cleaning. While the helpers work, the chef keeps moving. When a helper finishes, they tap the chef on the shoulder and the chef picks up from there.

Mapping that to JavaScript:

  • The chef = the JavaScript call stack
  • The helpers = Web APIs (setTimeout, fetch, DOM events)
  • The kitchen notepad = the task queue
  • The chef glancing at the notepad = the event loop

The Call Stack: Where Your Functions Live and Die

The call stack tracks where execution currently is in your program. Each time a function is invoked, it gets pushed onto the stack. When it returns, it gets popped off. That's the entire mechanism.

A Basic Call Stack Walkthrough

function first() {
    console.log("First function");
    second();
    console.log("Back to first");
}

function second() {
    console.log("Second function");
    third();
    console.log("Back to second");
}

function third() {
    console.log("Third function");
}

first();

Here is what the stack looks like at each step:

1. Stack: [ first() ]

2. first() calls console.log → stack: [ first(), console.log ] → returns → stack: [ first() ]

3. first() calls second() → stack: [ first(), second() ]

4. second() calls console.log → stack: [ first(), second(), console.log ] → returns → stack: [ first(), second() ]

5. second() calls third() → stack: [ first(), second(), third() ]

6. third() calls console.log → stack: [ first(), second(), third(), console.log ] → returns → stack: [ first(), second(), third() ]

7. third() returns → stack: [ first(), second() ]

8. second() resumes → calls console.log → returns → stack: [ first(), second() ]

9. second() returns → stack: [ first() ]

10. first() returns → stack: [ ]

When the Stack Gets Stuck

function blockTheStack() {
    const start = Date.now();
    while (Date.now() - start < 3000) {
        // Spins for 3 full seconds
    }
    console.log("Finally done!");
}

console.log("Start");
blockTheStack();
console.log("End");

// Output:
// Start
// (3 second freeze)
// Finally done!
// End

For those three seconds, nothing else runs. Clicks are ignored. Timers don't fire. Network responses sit in a queue going nowhere. The event loop cannot do its job until the stack clears - and right now, that loop is just waiting.

Rule of thumb: Avoid long synchronous operations in the main thread. If a task is heavy, break it into smaller pieces or move it off the call stack using async patterns.

Web APIs: The Real Home of Async Work

Functions like setTimeout, fetch, and addEventListener are not part of the JavaScript language itself - they are provided by the runtime environment. When you call them, the work is handed off to the environment, which processes it outside the JavaScript thread entirely.

Following a setTimeout Call

console.log("1");

setTimeout(function() {
    console.log("2");
}, 0);

console.log("3");

// Output:
// 1
// 3
// 2

Zero milliseconds and yet "2" still comes last. Here's the sequence:

What actually happens:

  1. console.log("1") runs on the call stack immediately
  2. setTimeout is invoked. The timer is handed to a Web API and JavaScript moves on
  3. console.log("3") runs on the call stack
  4. After 0ms, the Web API places the callback in the task queue
  5. The event loop sees an empty call stack and moves the callback to it
  6. The callback runs - printing "2"

With an Actual Delay

console.log("Start");

setTimeout(function() {
    console.log("Timeout finished");
}, 2000);

console.log("End");

// Output:
// Start
// End
// (2 seconds pass)
// Timeout finished

The callback sits patiently in the task queue. The event loop will not touch it until the call stack is completely empty and the timer has expired.

The Task Queue: A Waiting Room for Callbacks

The task queue (sometimes called the callback queue) stores callbacks that are ready to run. It works FIFO - first in, first out. When a Web API finishes its job, the resulting callback lands here and waits its turn.

What Ends Up in the Task Queue

  • setTimeout and setInterval callbacks
  • DOM event handlers (clicks, keypresses, scroll events)
  • Completed HTTP request callbacks from fetch or XHR
  • requestAnimationFrame callbacks

Multiple Timers in Action

setTimeout(function() {
    console.log("A");
}, 1000);

setTimeout(function() {
    console.log("B");
}, 500);

setTimeout(function() {
    console.log("C");
}, 0);

console.log("D");

// Output:
// D
// C
// B
// A

Why this order:

  1. "D" is synchronous - it runs first
  2. All three timers begin counting at roughly the same moment
  3. Timer C expires first (0ms) → callback goes to queue
  4. Timer B follows at 500ms → callback goes to queue
  5. Timer A arrives last at 1000ms → callback goes to queue
  6. The event loop processes them in the order they arrived

The Event Loop: Connecting Everything Together

The event loop is a continuously running process with a very simple job. It checks two things on every tick:

  1. Is the call stack empty?
  2. Is there anything pending in the task queue?

If both conditions are true, it takes the first item from the queue and pushes it to the call stack to be executed.

while (callStack.empty() && taskQueue.hasTasks()) {
    callStack.push(taskQueue.pop());
}

The event loop in pseudocode

Tracing the Event Loop Step by Step

console.log("1");

setTimeout(function() {
    console.log("2");
}, 0);

console.log("3");

Step 1: "1" pushed to call stack → executes → popped

Step 2: setTimeout pushed to call stack → timer handed to Web API → setTimeout returns → popped

Step 3: "3" pushed to call stack → executes → popped

Step 4: Call stack is now empty. Event loop checks the task queue

Step 5: Timer fires after 0ms. Callback moved to task queue

Step 6: Event loop moves callback from task queue to call stack

Step 7: Callback executes → prints "2"

Why the Event Loop Never Really Stops

function waitForClick() {
    console.log("Waiting for button click...");
}

document.getElementById("myButton").addEventListener("click", waitForClick);
console.log("Program continues running...");

// The event loop keeps the program alive, checking for new tasks like click events

Once the synchronous code finishes, JavaScript doesn't just quit. The event loop stays alive, watching for new tasks - click events, network responses, timers - ready to process them the moment they arrive.

Microtasks: How Promises Jump the Queue

There's a second queue you need to know about: the microtask queue. It has a higher priority than the regular task queue. Every time the call stack empties, the engine drains all microtasks before it even glances at the task queue.

What Goes into the Microtask Queue

  • Promise handlers - .then(), .catch(), .finally()
  • queueMicrotask() callbacks
  • MutationObserver callbacks

Microtasks vs Regular Tasks: A Direct Comparison

setTimeout(function() {
    console.log("Task queue (setTimeout)");
}, 0);

Promise.resolve().then(function() {
    console.log("Microtask queue (Promise)");
});

console.log("Synchronous");

// Output:
// Synchronous
// Microtask queue (Promise)
// Task queue (setTimeout)

Both were scheduled with zero delay - yet the Promise callback runs before setTimeout. That's the microtask queue's priority at work.

Watch out: Every microtask is processed before the engine moves to the next task. That means microtasks can queue more microtasks - and tasks will keep waiting until the microtask queue fully drains.

Recursive Microtasks (Tread Carefully)

function recursiveMicrotask(count) {
    console.log(count);
    
    if (count < 10) {
        queueMicrotask(function() {
            recursiveMicrotask(count + 1);
        });
    }
}

recursiveMicrotask(1);
setTimeout(function() {
    console.log("This will run after all microtasks finish");
}, 0);

// Output: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 - then the setTimeout fires

Each microtask adds the next one to the queue. The event loop keeps draining microtasks until there are none left. Only then does the setTimeout callback get a turn.

Async/Await and Microtasks

async function asyncExample() {
    console.log("A");
    await Promise.resolve();
    console.log("B");
}

console.log("C");
asyncExample();
console.log("D");

// Output: C, A, D, B

Why does "B" come after "D"?

  1. "C" runs synchronously
  2. asyncExample begins, logs "A"
  3. await suspends the function and schedules the rest as a microtask
  4. "D" runs because the call stack still has work to do
  5. Once the stack empties, the microtask resumes and "B" prints

Step-by-Step Examples You Can Trace

Example 1: Synchronous, Promises, and Timers Together

console.log("Start");

setTimeout(() => {
    console.log("Timeout 1");
}, 0);

Promise.resolve()
    .then(() => {
        console.log("Promise 1");
    })
    .then(() => {
        console.log("Promise 2");
    });

setTimeout(() => {
    console.log("Timeout 2");
}, 0);

console.log("End");

Output:

Start
End
Promise 1
Promise 2
Timeout 1
Timeout 2

Breakdown:

  • Synchronous code runs first, giving us "Start" and "End"
  • Microtasks drain next: "Promise 1" then "Promise 2"
  • Task queue callbacks run last: "Timeout 1" then "Timeout 2"

Example 2: A Promise Scheduling Another Promise

setTimeout(() => {
    console.log("Timeout 1");
}, 0);

Promise.resolve()
    .then(() => {
        console.log("Promise 1");
        Promise.resolve()
            .then(() => {
                console.log("Promise 2 (nested)");
            });
    })
    .then(() => {
        console.log("Promise 3");
    });

console.log("Sync");

// Output:
// Sync
// Promise 1
// Promise 2 (nested)
// Promise 3
// Timeout 1

The inner Promise creates a new microtask mid-flight. Since the microtask queue must fully empty before any task runs, this nested Promise still executes ahead of the setTimeout callback.

Example 3: Click Events and Timers Racing

// In a browser environment
let counter = 0;

document.getElementById("myButton").addEventListener("click", () => {
    console.log("Click event");
    counter++;
});

setTimeout(() => {
    console.log("Timeout callback");
}, 0);

console.log("Script end");

// If the button is clicked immediately after the script runs:
// Output order between "Click event" and "Timeout callback" depends on which arrives first

Click callbacks and setTimeout callbacks compete for the same task queue. Whichever arrives first gets processed first. The event loop is fair - it respects arrival order.

Example 4: A Long Task Delays a Timer

setTimeout(() => {
    console.log("Timeout ran!");
}, 0);

function longRunningTask() {
    const start = Date.now();
    while (Date.now() - start < 3000) {
        // Blocks execution for 3 seconds
    }
    console.log("Long task finished");
}

longRunningTask();
console.log("After long task");

// Output:
// Long task finished
// After long task
// (then eventually)
// Timeout ran!

The timer callback was queued immediately, but it cannot run while the call stack is occupied. The synchronous loop holds the stack hostage for three seconds - the timer fires when it can, not when you asked it to.

Common Misconceptions About the Event Loop

Misconception: "setTimeout(fn, 0) fires instantly"

Reality: It runs after all synchronous code and any queued microtasks finish. The number is a minimum delay, not a guarantee.

Misconception: "JavaScript must be multi-threaded"

Reality: JavaScript executes on one thread. Web APIs use their own threads, but every callback eventually runs on the same single JavaScript thread.

Misconception: "Promises run in parallel"

Reality: Promise callbacks are microtasks. They don't run in parallel - they run after current synchronous code ends, but before any setTimeout callbacks.

Misconception: "The event loop runs alongside your code"

Reality: The event loop only activates when the call stack is empty. It doesn't run concurrently with your code - it steps in between executions.

The Node.js Event Loop: A Few Extra Phases

Node.js has its own event loop implementation, but it adds several phases to account for file I/O, networking, and operating system callbacks.

Node.js Event Loop Phases (in order):

  1. Timers - runs setTimeout and setInterval callbacks
  2. Pending callbacks - handles I/O callbacks deferred from the previous cycle
  3. Idle, prepare - used internally by Node.js
  4. Poll - fetches new I/O events from the OS
  5. Check - executes setImmediate callbacks
  6. Close callbacks - handles cleanup for closed connections or handles

setTimeout vs setImmediate in Node.js

// In Node.js
setTimeout(() => {
    console.log("setTimeout");
}, 0);

setImmediate(() => {
    console.log("setImmediate");
});

// Execution order is not deterministic here.
// It depends on how fast the timers phase is entered.

process.nextTick: Higher Priority Than Promises

// Node.js specific
setTimeout(() => {
    console.log("Timeout");
}, 0);

Promise.resolve().then(() => {
    console.log("Promise");
});

process.nextTick(() => {
    console.log("nextTick");
});

console.log("Sync");

// Output:
// Sync
// nextTick
// Promise
// Timeout

process.nextTick callbacks run immediately after the current operation completes - before microtasks and well before any task queue callbacks. They occupy the top priority slot in Node.js.

Practical Tips for Working with the Event Loop

Keep the Stack Clear for Long Tasks

// Problem: blocking the entire thread
function badProcessing(data) {
    for (let i = 0; i < data.length; i++) {
        // expensive operation for every item
    }
    return result;
}

// Better: process in chunks and yield between them
function goodProcessing(data, index = 0) {
    if (index >= data.length) return;
    
    const chunk = data.slice(index, index + 100);
    // process this batch...
    
    // Yield control back to the event loop before the next batch
    setTimeout(() => goodProcessing(data, index + 100), 0);
}

Avoid Runaway Microtask Loops

// Dangerous: tasks will never get a chance to run
function dangerousLoop() {
    Promise.resolve().then(() => {
        dangerousLoop(); // keeps the microtask queue permanently full
    });
}

// Safe alternative: use setTimeout for recursive patterns
function safeRecursion(count) {
    if (count <= 0) return;
    console.log(count);
    setTimeout(() => safeRecursion(count - 1), 0); // yields between each step
}

Use Microtasks for High-Priority Async Work

// queueMicrotask runs before setTimeout callbacks
function updateUI() {
    queueMicrotask(() => {
        console.log("UI update - runs with high priority");
    });
}

setTimeout(() => console.log("Background work"), 0);
updateUI();
// Output: "UI update - runs with high priority" then "Background work"

Wrapping Up: The Event Loop in Plain Terms

One-sentence summary

The event loop is the process that watches the call stack and, whenever it's empty, pulls the next waiting callback into it - keeping JavaScript moving without ever needing true parallelism.

Key Takeaways

  • JavaScript runs on a single call stack - one task at a time
  • Web APIs (setTimeout, fetch, DOM events) live outside the JavaScript thread
  • Completed async work is placed into the task queue as a callback
  • The event loop transfers tasks to the call stack only when the stack is clear
  • Microtasks (Promises) always run before regular tasks
  • The entire microtask queue drains before the next task begins
  • A blocked call stack stalls everything - avoid long synchronous loops

Execution priority at a glance

  1. Current synchronous code on the call stack
  2. All pending microtasks (Promise callbacks, queueMicrotask)
  3. One callback from the task queue (setTimeout, events, I/O)
  4. Back to step 2 - repeat indefinitely

The event loop explains so many JavaScript quirks: why setTimeout with zero delay still runs last, why Promises behave differently from timers, and why a frozen page means something is hogging the call stack.

Once you internalize this model, asynchronous JavaScript stops feeling like magic and starts feeling like a system you can reason about - and even predict.

Practice challenge

Write a function that logs the numbers 1 through 5 with a one-second gap between each output. Try implementing it with setTimeout, then with async/await. For each version, trace which queue every callback enters. Doing this manually will make the model click in a way that reading alone never quite does.

The event loop is not mysterious - it is a precise, well-documented mechanism. Understand it deeply and you stop fighting JavaScript's behavior. You start writing code that works with it instead.

0 Comments
Share:

Discussion

0 Comments

Join the conversation

Login to share your thoughts with the community

Related Tutorials