From Zero to AI

Lesson 1.4: Visualizing the Event Loop

Duration: 70 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  • Use visualization tools to understand async code execution
  • Trace complex async code step by step
  • Predict execution order in challenging scenarios
  • Debug timing-related issues in JavaScript
  • Understand the difference between macrotasks and microtasks

Visualization Tools

The best way to understand the event loop is to see it in action. Here are some excellent tools:

Loupe - Event Loop Visualizer

Loupe by Philip Roberts is an interactive tool that shows:

  • The call stack
  • Web APIs
  • The callback queue
  • The event loop in action

Try pasting this code into Loupe:

console.log('Start');

setTimeout(function timer() {
  console.log('Timer');
}, 1000);

console.log('End');

Watch how:

  1. "Start" and "End" go straight through the call stack
  2. The timer callback moves to Web APIs
  3. After 1 second, it enters the callback queue
  4. The event loop moves it to the call stack when empty

JavaScript Visualizer 9000

JS Visualizer 9000 shows:

  • Execution context
  • Task queue
  • Microtask queue
  • Visual stepping through code

Step-by-Step Tracing

Let us trace this code manually:

console.log('1');

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

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

console.log('4');

The Trace

Final output: 1, 4, 3, 2


Macrotasks vs Microtasks

JavaScript has two types of async queues:

Macrotasks (Task Queue)

  • setTimeout
  • setInterval
  • setImmediate (Node.js)
  • I/O operations
  • UI rendering

Microtasks (Microtask Queue)

  • Promise.then/catch/finally
  • queueMicrotask
  • MutationObserver
  • process.nextTick (Node.js)

Priority Order

The event loop processes ALL microtasks before moving to the next macrotask.

Example: Microtasks vs Macrotasks

console.log('Script start');

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

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

console.log('Script end');

Output:

Script start
Script end
Promise 1
Promise 2
setTimeout

Both Promise callbacks run before setTimeout, even though setTimeout was registered first!


Complex Examples

Example 1: Nested Promises and Timers

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});

console.log('6');
What is the output?
1
6
4
2
3
5

Explanation:

  1. "1" - sync
  2. setTimeout registered (macrotask)
  3. Promise.then registered (microtask)
  4. "6" - sync
  5. Stack empty, process microtasks: "4", new setTimeout registered
  6. Process macrotask (first setTimeout): "2", new microtask added
  7. Process microtasks: "3"
  8. Process macrotask (second setTimeout): "5"

Example 2: Multiple Microtask Sources

console.log('Start');

queueMicrotask(() => {
  console.log('Microtask 1');
});

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

queueMicrotask(() => {
  console.log('Microtask 2');
});

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

console.log('End');
What is the output?
Start
End
Microtask 1
Promise 1
Microtask 2
Promise 2

Microtasks are processed in the order they were added to the queue.

Example 3: The Tricky One

async function async1(): Promise<void> {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2(): Promise<void> {
  console.log('async2');
}

console.log('script start');

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

async1();

new Promise<void>((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
});

console.log('script end');
What is the output?
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

Explanation:

  1. "script start" - sync
  2. setTimeout registered
  3. async1() called: "async1 start", async2() called: "async2"
  4. await pauses async1, rest becomes microtask
  5. Promise executor runs: "promise1", .then becomes microtask
  6. "script end" - sync
  7. Microtasks: "async1 end", "promise2"
  8. Macrotask: "setTimeout"

Debugging Async Code

Using console.log with Timestamps

function log(message: string): void {
  const time = new Date().toISOString().substr(11, 12);
  console.log(`[${time}] ${message}`);
}

log('Start');

setTimeout(() => log('Timeout 1'), 1000);
setTimeout(() => log('Timeout 2'), 500);

Promise.resolve().then(() => log('Promise'));

log('End');

Output:

[12:00:00.000] Start
[12:00:00.001] End
[12:00:00.001] Promise
[12:00:00.502] Timeout 2
[12:00:01.001] Timeout 1

Using Labels

console.log('--- Sync Phase ---');
console.log('A');

setTimeout(() => {
  console.log('--- Macrotask 1 ---');
  console.log('B');
}, 0);

Promise.resolve().then(() => {
  console.log('--- Microtask 1 ---');
  console.log('C');
});

console.log('D');
console.log('--- End Sync Phase ---');

Common Pitfalls

Pitfall 1: Assuming setTimeout(fn, 0) Runs Immediately

let value = 'initial';

setTimeout(() => {
  value = 'changed';
}, 0);

console.log(value); // "initial" - not "changed"!

Even with 0ms delay, the callback waits for the call stack to clear.

Pitfall 2: Forgetting Microtask Priority

let result = '';

setTimeout(() => {
  result += 'A';
}, 0);

Promise.resolve().then(() => {
  result += 'B';
});

// Later...
setTimeout(() => {
  console.log(result); // "BA" - not "AB"!
}, 10);

Pitfall 3: Blocking the Event Loop

setTimeout(() => {
  console.log('This should print after 100ms');
}, 100);

// This blocks for 2 seconds!
const start = Date.now();
while (Date.now() - start < 2000) {
  // Blocking loop
}

// The timeout callback runs after 2 seconds, not 100ms!

The event loop cannot process callbacks while synchronous code is running.


Building a Mental Model

Think of the event loop like this:


Exercises

Exercise 1: Trace This Code

Draw the state of the call stack, microtask queue, and macrotask queue at each step:

console.log('A');

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

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

console.log('D');
Solution
Step 1: console.log("A")
  Stack: [main, log]  Output: A
  Microtasks: []
  Macrotasks: []

Step 2: Promise.then registered
  Stack: [main]
  Microtasks: [B-callback]
  Macrotasks: []

Step 3: setTimeout registered
  Stack: [main]
  Microtasks: [B-callback]
  Macrotasks: [C-callback]

Step 4: console.log("D")
  Stack: [main, log]  Output: D
  Microtasks: [B-callback]
  Macrotasks: [C-callback]

Step 5: main completes, process microtasks
  Stack: [B-callback]  Output: B
  Microtasks: []
  Macrotasks: [C-callback]

Step 6: process macrotasks
  Stack: [C-callback]  Output: C
  Microtasks: []
  Macrotasks: []

Final output: A, D, B, C

Exercise 2: Fix the Bug

This code should print "Processing complete!" after all items are processed. Fix it:

const items = [1, 2, 3];

items.forEach((item) => {
  setTimeout(() => {
    console.log('Processed:', item);
  }, 100);
});

console.log('Processing complete!');
Solution
const items = [1, 2, 3];
let processed = 0;

items.forEach((item) => {
  setTimeout(() => {
    console.log('Processed:', item);
    processed++;

    if (processed === items.length) {
      console.log('Processing complete!');
    }
  }, 100);
});

Or using Promises (preferred):

const items = [1, 2, 3];

const promises = items.map((item) => {
  return new Promise<void>((resolve) => {
    setTimeout(() => {
      console.log('Processed:', item);
      resolve();
    }, 100);
  });
});

Promise.all(promises).then(() => {
  console.log('Processing complete!');
});

Exercise 3: Predict the Order

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve()
  .then(() => console.log('3'))
  .then(() => console.log('4'));

Promise.resolve().then(() => {
  console.log('5');
  setTimeout(() => console.log('6'), 0);
});

console.log('7');
Solution
1
7
3
5
4
2
6

Explanation:

  1. "1" - sync
  2. setTimeout(2) registered as macrotask
  3. Promise chain (3, 4) registered as microtasks
  4. Promise (5, setTimeout(6)) registered as microtask
  5. "7" - sync
  6. Process microtasks in order:
    • "3" prints, "4" queued
    • "5" prints, setTimeout(6) registered
    • "4" prints
  7. Process macrotasks:
    • "2" prints
    • "6" prints

Exercise 4: Real-World Scenario

You are loading a user profile. Predict the console output:

console.log('Loading profile...');

// Simulate API call
const fetchUser = new Promise<string>((resolve) => {
  console.log('Fetching user data...');
  setTimeout(() => {
    resolve('User: Alice');
  }, 100);
});

fetchUser
  .then((user) => {
    console.log(user);
    console.log('Fetching posts...');

    return new Promise<string>((resolve) => {
      setTimeout(() => {
        resolve('Posts loaded');
      }, 50);
    });
  })
  .then((posts) => {
    console.log(posts);
  });

setTimeout(() => {
  console.log('Timeout 1');
}, 100);

console.log('Profile component rendered');
Solution
Loading profile...
Fetching user data...
Profile component rendered
Timeout 1       (after ~100ms - may race with next line)
User: Alice     (after ~100ms)
Fetching posts...
Posts loaded    (after ~150ms total)

Note: "Timeout 1" and "User: Alice" both trigger around 100ms, so their order may vary depending on exact timing. In most JavaScript engines, they will appear in this order since they were registered at the same time and setTimeout callbacks are processed in registration order.


Key Takeaways

  1. Use visualization tools like Loupe and JS Visualizer to understand async code
  2. Microtasks (Promises) have higher priority than macrotasks (setTimeout)
  3. All microtasks run before the next macrotask
  4. The event loop constantly checks if the stack is empty to process queued callbacks
  5. setTimeout(fn, 0) does not run immediately - it waits for the stack to clear
  6. Blocking code prevents the event loop from processing callbacks
  7. Trace code step by step to understand complex async behavior

Resources

Resource Type Description
Loupe Tool Interactive event loop visualizer
JS Visualizer 9000 Tool Detailed async visualization
Jake Archibald: In The Loop Video Deep dive into the event loop
MDN: Microtask Guide Documentation Official microtask documentation

Module Summary

Congratulations! You have completed Module 1. You now understand:

  • How JavaScript engines execute code
  • The single-threaded nature of JavaScript
  • How the call stack tracks function execution
  • The role of Web APIs in handling async operations
  • The difference between callback queue and microtask queue
  • How the event loop coordinates everything

This foundation is essential for understanding Promises and async/await, which we will cover in the next modules.


Next Module

You are now ready to learn about Promises - a better way to handle asynchronous code that avoids callback hell.

Continue to Module 2: Promises