DEV Community

Cover image for # Week 3 - Task 2: Understanding JavaScript Core Concepts
koushikmaya
koushikmaya

Posted on

# Week 3 - Task 2: Understanding JavaScript Core Concepts

JavaScript Event Loop Explained: Call Stack, Web APIs, Queues, Promises and setTimeout

If you've ever looked at JavaScript code like this:

console.log("Start");

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

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

console.log("End");
Enter fullscreen mode Exit fullscreen mode

and wondered:

"Why does Promise run before setTimeout, even though the timer is 0ms?"

You're not alone.

To understand this, we need to understand a few important JavaScript concepts:

  • Call Stack
  • Web APIs / Node.js APIs
  • Callback Queue
  • Macrotasks
  • Microtask Queue
  • Event Loop
  • Promises
  • setTimeout()

Once these pieces fit together, asynchronous JavaScript becomes much easier to reason about.


1. Is JavaScript really single-threaded?

JavaScript code is executed by an execution environment that processes one piece of JavaScript at a time on a given thread.

That means JavaScript doesn't normally execute two JavaScript functions simultaneously on the same execution thread.

So if we write:

console.log("A");
console.log("B");
console.log("C");
Enter fullscreen mode Exit fullscreen mode

the execution happens in order:

A
B
C
Enter fullscreen mode Exit fullscreen mode

This is possible because JavaScript uses a Call Stack to keep track of what is currently executing.

But then we have a problem.

What happens when JavaScript needs to wait for:

  • a timer?
  • a network request?
  • a file operation?
  • a user click?
  • some other asynchronous operation?

JavaScript shouldn't freeze the entire application while waiting.

This is where the runtime environment and event loop come in.


2. The Call Stack

The Call Stack is a data structure used to keep track of JavaScript execution contexts.

It follows the LIFO principle:

Last In, First Out.

Consider:

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

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

first();
second();
Enter fullscreen mode Exit fullscreen mode

When first() is called, it is placed on the stack.

Call Stack

┌─────────────┐
│   first()   │
└─────────────┘
Enter fullscreen mode Exit fullscreen mode

Inside first(), console.log() is executed.

Once first() finishes, it is removed from the stack.

Then second() is executed.

first()
  ↓
console.log()
  ↓
first() finishes
  ↓
second()
  ↓
console.log()
  ↓
second() finishes
Enter fullscreen mode Exit fullscreen mode

The Call Stack is therefore responsible for what JavaScript is executing right now.

Simple definition

The Call Stack is a LIFO structure that keeps track of currently executing JavaScript functions.


3. What happens with asynchronous code?

Now consider:

console.log("Start");

setTimeout(() => {
  console.log("Hello");
}, 1000);

console.log("End");
Enter fullscreen mode Exit fullscreen mode

A common beginner assumption is:

Start
wait 1 second
Hello
End
Enter fullscreen mode Exit fullscreen mode

But that's not what happens.

The output is:

Start
End
Hello
Enter fullscreen mode Exit fullscreen mode

Why?

Because JavaScript doesn't keep the setTimeout() callback on the Call Stack for one second.

Instead, the timer is handled by the surrounding runtime environment.

In a browser, this involves Web APIs.

In Node.js, the runtime provides its own APIs and event-loop mechanisms for timers, I/O, and other asynchronous work.


4. Web APIs

When JavaScript runs inside a browser, the browser provides APIs that JavaScript can use.

These include things such as:

setTimeout()
setInterval()
fetch()
DOM events
XMLHttpRequest
Enter fullscreen mode Exit fullscreen mode

These capabilities are provided by the browser environment rather than being implemented as ordinary JavaScript functions inside your program.

For example:

setTimeout(() => {
  console.log("Hello");
}, 1000);
Enter fullscreen mode Exit fullscreen mode

Conceptually:

JavaScript
    │
    │ setTimeout()
    ▼
Browser runtime
    │
    │ waits for timer
    ▼
Callback becomes eligible
Enter fullscreen mode Exit fullscreen mode

Meanwhile, JavaScript can continue executing:

console.log("This runs immediately");
Enter fullscreen mode Exit fullscreen mode

That's one of the reasons asynchronous programming is so important in JavaScript.


5. Node.js APIs

When JavaScript runs in Node.js, there is no browser providing Web APIs.

Instead, Node.js provides runtime APIs and an event-loop implementation for things such as:

  • timers
  • file system operations
  • networking
  • other I/O operations

For example:

const fs = require("fs");

fs.readFile("data.txt", "utf8", (err, data) => {
  console.log(data);
});

console.log("Reading file...");
Enter fullscreen mode Exit fullscreen mode

The file operation can be handled by the Node.js runtime while JavaScript continues executing other code.

So a useful mental model is:

Browser JavaScript
        ↓
Browser APIs

Node.js JavaScript
        ↓
Node.js APIs / runtime
Enter fullscreen mode Exit fullscreen mode

The exact internals differ between browsers and Node.js, so it's better not to think of the browser and Node.js event loops as identical implementations.


6. Callback Queue

When an asynchronous operation finishes, the callback needs somewhere to wait until JavaScript can execute it.

This is where task queues come into the picture.

For example:

setTimeout(() => {
  console.log("Timer finished");
}, 0);
Enter fullscreen mode Exit fullscreen mode

Once the timer is eligible, its callback can be queued as a task.

A simplified mental model looks like:

Timer
  │
  ▼
Task Queue
  │
  ▼
Event Loop
  │
  ▼
Call Stack
Enter fullscreen mode Exit fullscreen mode

You may hear this queue called the Callback Queue, Task Queue, or Macrotask Queue.

Strictly speaking, the modern web platform uses the terminology tasks and microtasks rather than treating everything as one generic "callback queue."


7. What is a Macrotask?

The term macrotask is commonly used by developers when discussing tasks such as timer callbacks.

For example:

setTimeout(() => {
  console.log("Hello");
}, 0);
Enter fullscreen mode Exit fullscreen mode

The callback is scheduled as a task.

Other examples can include callbacks associated with:

setTimeout()
setInterval()
user events
some I/O operations
Enter fullscreen mode Exit fullscreen mode

The exact task scheduling behavior depends on the runtime and API involved.

A simplified model is:

Macrotask / Task Queue

┌──────────────────────┐
│ setTimeout callback  │
├──────────────────────┤
│ event callback       │
├──────────────────────┤
│ another task         │
└──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

8. What is a Microtask?

Now we get to the interesting part.

A microtask is a type of asynchronous work that is processed at specific points in the event loop, before the runtime proceeds to the next task.

Promises use the microtask queue for their reaction callbacks.

For example:

Promise.resolve().then(() => {
  console.log("Promise");
});
Enter fullscreen mode Exit fullscreen mode

The function passed to .then() does not execute immediately.

Instead, it is scheduled as a microtask.

Other ways to create microtasks include:

queueMicrotask(() => {
  console.log("Microtask");
});
Enter fullscreen mode Exit fullscreen mode

The simplified picture is:

Microtask Queue

┌─────────────────────┐
│ Promise.then()      │
├─────────────────────┤
│ Promise.catch()     │
├─────────────────────┤
│ queueMicrotask()    │
└─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

9. Why do Promises run before setTimeout?

This is the question that usually makes the event loop finally "click."

Consider:

console.log("Start");

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

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

console.log("End");
Enter fullscreen mode Exit fullscreen mode

The output is:

Start
End
Promise
setTimeout
Enter fullscreen mode Exit fullscreen mode

Let's understand why.

Step 1: Synchronous code

First:

console.log("Start");
Enter fullscreen mode Exit fullscreen mode

prints:

Start
Enter fullscreen mode Exit fullscreen mode

Then:

setTimeout(..., 0);
Enter fullscreen mode Exit fullscreen mode

schedules a timer callback.

Next:

Promise.resolve().then(...);
Enter fullscreen mode Exit fullscreen mode

schedules the Promise reaction as a microtask.

Finally:

console.log("End");
Enter fullscreen mode Exit fullscreen mode

prints:

End
Enter fullscreen mode Exit fullscreen mode

So the synchronous output is:

Start
End
Enter fullscreen mode Exit fullscreen mode

10. What is waiting in the queues?

After the synchronous code finishes, conceptually we have:

Microtask Queue

Promise
Enter fullscreen mode Exit fullscreen mode

and:

Task / Macrotask Queue

setTimeout
Enter fullscreen mode Exit fullscreen mode

The runtime processes the microtask queue before moving on to the next task.

Therefore:

Promise
Enter fullscreen mode Exit fullscreen mode

runs first.

Then:

setTimeout
Enter fullscreen mode Exit fullscreen mode

runs.

Final output:

Start
End
Promise
setTimeout
Enter fullscreen mode Exit fullscreen mode

MDN describes Promise callbacks as microtasks and setTimeout() callbacks as tasks, with microtasks processed before the next task.


11. setTimeout(..., 0) does NOT mean "run immediately"

This is an important misconception.

When you write:

setTimeout(() => {
  console.log("Hello");
}, 0);
Enter fullscreen mode Exit fullscreen mode

0 does not mean:

Execute this function right now.

It means the timer is configured with a zero-millisecond delay, after which the callback becomes eligible to be scheduled.

The callback still has to wait for the current JavaScript execution to finish and for the event loop to reach that task.

For example:

console.log("A");

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

console.log("C");
Enter fullscreen mode Exit fullscreen mode

Output:

A
C
B
Enter fullscreen mode Exit fullscreen mode

The timer doesn't interrupt the currently running JavaScript.


12. Promises don't execute immediately either

There's another important misconception.

Some developers see:

Promise.resolve().then(() => {
  console.log("Promise");
});
Enter fullscreen mode Exit fullscreen mode

and think the callback runs immediately because the Promise is already resolved.

It doesn't.

Consider:

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

console.log("Normal");
Enter fullscreen mode Exit fullscreen mode

Output:

Normal
Promise
Enter fullscreen mode Exit fullscreen mode

The .then() callback is scheduled as a microtask.

MDN explicitly notes that Promise .then() callbacks are not called synchronously, even when the Promise is already resolved.


13. The Event Loop

The Event Loop is the mechanism that coordinates JavaScript execution with queued tasks and microtasks.

A simplified model is:

              ┌───────────────┐
              │  Call Stack   │
              └───────┬───────┘
                      │
                      ▼
              Stack becomes empty
                      │
                      ▼
             ┌─────────────────┐
             │ Microtask Queue │
             └────────┬────────┘
                      │
                Drain microtasks
                      │
                      ▼
             ┌─────────────────┐
             │   Task Queue    │
             └────────┬────────┘
                      │
                 Run a task
                      │
                      ▼
             Drain microtasks
                      │
                      ▼
                 Next task
Enter fullscreen mode Exit fullscreen mode

The important idea is:

The runtime doesn't simply alternate between "stack" and "queue." After a task finishes, pending microtasks are processed before the next task is taken.

MDN describes the event loop as processing a task and then pending microtasks, with newly queued microtasks also being processed before moving on.


14. A More Interesting Example

Let's make things slightly harder:

console.log("1");

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

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

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

console.log("5");
Enter fullscreen mode Exit fullscreen mode

What will be printed?

First, synchronous code:

1
5
Enter fullscreen mode Exit fullscreen mode

Then microtasks:

3
4
Enter fullscreen mode Exit fullscreen mode

Then the timer:

2
Enter fullscreen mode Exit fullscreen mode

So the final output is:

1
5
3
4
2
Enter fullscreen mode Exit fullscreen mode

The key is not to memorize the output.

Instead, identify where each callback goes.

console.log("1")
        ↓
Synchronous

setTimeout(...)
        ↓
Task

Promise.then(...)
        ↓
Microtask

Promise.then(...)
        ↓
Microtask

console.log("5")
        ↓
Synchronous
Enter fullscreen mode Exit fullscreen mode

15. Microtasks Can Create More Microtasks

This is where the event loop gets even more interesting.

Consider:

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

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

  Promise.resolve().then(() => {
    console.log("C");
  });
});
Enter fullscreen mode Exit fullscreen mode

Output:

B
C
A
Enter fullscreen mode Exit fullscreen mode

Why?

Initially:

Microtask Queue:
B

Task Queue:
A
Enter fullscreen mode Exit fullscreen mode

The microtask B runs:

B
Enter fullscreen mode Exit fullscreen mode

While running B, another Promise callback is created:

Microtask Queue:
C
Enter fullscreen mode Exit fullscreen mode

The runtime continues processing microtasks until the microtask queue is empty.

So:

C
Enter fullscreen mode Exit fullscreen mode

runs before the timer.

Finally:

A
Enter fullscreen mode Exit fullscreen mode

runs.

MDN notes that newly added microtasks are processed before the next task, which is also why continuously creating microtasks can potentially prevent the runtime from reaching later tasks.


16. A Macrotask Can Create a Microtask

Now reverse the situation:

setTimeout(() => {
  console.log("A");

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

setTimeout(() => {
  console.log("C");
}, 0);
Enter fullscreen mode Exit fullscreen mode

Output:

A
B
C
Enter fullscreen mode Exit fullscreen mode

Why isn't it:

A
C
B
Enter fullscreen mode Exit fullscreen mode

When the first timer runs:

A
Enter fullscreen mode Exit fullscreen mode

it creates a Promise microtask.

That microtask is processed before the runtime proceeds to the next task:

A
B
C
Enter fullscreen mode Exit fullscreen mode

This is one of the most useful patterns to understand when debugging asynchronous JavaScript.


17. A Complete Example

Consider this:

console.log("A");

setTimeout(() => {
  console.log("B");

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

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

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

console.log("F");
Enter fullscreen mode Exit fullscreen mode

Let's trace it.

First: synchronous code

A
F
Enter fullscreen mode Exit fullscreen mode

Then: microtask

D
Enter fullscreen mode Exit fullscreen mode

During D, another timer is scheduled:

E
Enter fullscreen mode Exit fullscreen mode

Next: first timer

B
Enter fullscreen mode Exit fullscreen mode

During B, another Promise microtask is created.

So:

C
Enter fullscreen mode Exit fullscreen mode

runs before the next timer.

Finally:

E
Enter fullscreen mode Exit fullscreen mode

The output is:

A
F
D
B
C
E
Enter fullscreen mode Exit fullscreen mode

18. The Mental Model I Use

When I see asynchronous JavaScript, I don't try to guess the output immediately.

I ask three questions:

Question 1: Is this synchronous?

If yes, it runs on the Call Stack immediately.

Question 2: If it's asynchronous, which queue does it use?

For example:

Promise.then()
        ↓
Microtask

setTimeout()
        ↓
Task / Macrotask
Enter fullscreen mode Exit fullscreen mode

Question 3: Are there microtasks waiting?

After a task completes, microtasks are processed before the next task.

This makes complicated examples much easier to solve.


19. Quick Comparison

Feature Call Stack Microtask Queue Task / Macrotask Queue
Purpose Execute current JS Schedule microtasks Schedule tasks
Examples Function calls Promise callbacks setTimeout()
Executes Immediately while active After current task/stack During a later event-loop iteration
Priority Current execution Before next task After microtasks
Can contain Promise callbacks? No Yes No
Can contain timer callbacks? No No Yes

20. The Most Important Ordering Rule

If you remember only one thing from this article, remember this:

Synchronous JavaScript
        ↓
Microtasks
        ↓
Next Task
        ↓
Microtasks
        ↓
Next Task
Enter fullscreen mode Exit fullscreen mode

Or, more practically:

Promise.then()
        ↓
before
        ↓
setTimeout()
Enter fullscreen mode Exit fullscreen mode

when both are scheduled during the same synchronous turn.

This is why:

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

Promise.resolve().then(() => {
  console.log("Promise");
});
Enter fullscreen mode Exit fullscreen mode

produces:

Promise
Timeout
Enter fullscreen mode Exit fullscreen mode

21. Common Mistakes

Mistake 1: "0ms means immediate"

setTimeout(fn, 0);
Enter fullscreen mode Exit fullscreen mode

❌ Not immediate.

It schedules a task that can run after the timer becomes eligible and the event loop gets to it.


Mistake 2: "Promises are synchronous"

Promise.resolve().then(fn);
Enter fullscreen mode Exit fullscreen mode

❌ The .then() callback is asynchronous.

It is scheduled as a microtask.


Mistake 3: "The event loop executes everything at once"

❌ JavaScript execution on a given thread proceeds one job/task at a time.

The event loop coordinates when queued work gets a chance to execute.


Mistake 4: "Every asynchronous callback goes into the same queue"

❌ Not quite.

The runtime distinguishes between tasks and microtasks, and their scheduling rules differ.

Promises use microtasks, while timer callbacks such as setTimeout() are tasks.


22. Final Cheat Sheet

CALL STACK
→ Where current JavaScript executes.

WEB APIs
→ Browser-provided asynchronous capabilities.

NODE.JS APIs
→ Runtime APIs for Node.js timers, I/O, networking, etc.

CALLBACK
→ A function that can be invoked later.

TASK / MACROTASK
→ A scheduled unit of work such as a timer callback.

MICROTASK
→ High-priority queued work processed before the next task.

MICROTASK QUEUE
→ Where Promise reaction callbacks are scheduled.

EVENT LOOP
→ Coordinates execution of tasks and microtasks.

PROMISE
→ Represents the eventual completion or failure of an asynchronous operation.

setTimeout()
→ Schedules a timer callback as a task.

PROMISE.then()
→ Schedules a reaction callback as a microtask.
Enter fullscreen mode Exit fullscreen mode

And the core ordering:

┌─────────────────────┐
│ Synchronous Code    │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│ Microtask Queue     │
│ Promise.then()      │
│ Promise.catch()     │
│ queueMicrotask()    │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│ Task Queue          │
│ setTimeout()        │
│ events / other tasks│
└──────────┬──────────┘
           ↓
     Microtasks again
           ↓
       Next task
Enter fullscreen mode Exit fullscreen mode

Once you understand Call Stack → Runtime APIs → Task Queue → Microtask Queue → Event Loop, you can predict most common JavaScript asynchronous output questions without memorizing them.


References

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.