↑ Top

Understanding Javascript Heartbeat Queues Loops

ullas kunder

Designer & Developer

Understanding Javascript heartbeat: queues, loops & Async

Javascript is everywhere. 🀯 From powering interactive websites to driving server-side applications, it's the engine of the modern web. But how does it handle so many tasks, like user clicks, data fetching, and animations πŸ€–, all without freezing up? πŸ‘Ύ The secret lies in something called the Event Loop and its associated Task Queues.

This blog post will unravel the "what, why, and how" of these core concepts. we will explore:

  • What are Task Queues and the Event Loop?πŸ€”
  • Why do we need them? (The problem of synchronous code)🧐
  • How do they work together to create a smooth, responsive user experience?πŸ˜ƒ

we will also peer into the πŸš’ Javascript Engine's Execution Context and Call Stack, tracing the full lifecycle of a task, even touching on how loops interact with this intricate dance. And yes, we will build some simple, illustrative code examples along the way to make these powerful ideas intuitive!

πŸ“ I’m jotting down everything I know and understand about this concept in Javascript as part of my learning journey. I’m constantly exploring, experimenting, and updating myself πŸš€. If you have thoughts, suggestions, or ideas πŸ’‘, feel free to drop a comment at https://ullaskunder.tech/guestbook. I’d love to hear from you! ❀️

THANKS

I would like to express my gratitude to the following YouTube creators for their impactful videos that greatly aided my understanding of Javascript core concepts:

  • CodeSmith (Will Sentance): "Javascript The Hard Parts: Object Oriented Programming"
  • Kyle Simpson: "You Don't Know JS" (also available on YouTube)
  • Dave Gray: YouTube Javascript Playlist
  • Enes Karakaş: Advanced Object Concepts (available on YouTube)
  • Kyle Cook: Web Dev Simplified (available on YouTube)
  • Keith Peters: @codingmat (available on YouTube)

The "What": Execution Context, Call Stack, and the Event Loop's Mechanism

Before we dive into queues, lets establish the fundamental environment where Javascript code runs.

The Execution Context: Javascript's Workspace πŸ€“

When Javascript code executes, it does so within an Execution Context πŸ˜ƒ

Think of this as a special, isolated environment where a piece of code runs.

Each function call creates its own new execution context. This context contains:

  1. Variable Environment: Where variables (like var, let, const) and function declarations are stored for the current scope.
  2. Lexical Environment: This is closely related to the variable environment and determines how scope chaining works (how Javascript finds variables in outer scopes).
  3. this binding: The value of the this keyword for that context.

There is always a Global Execution Context (for code not inside any function), and then a new Function Execution Context is created every time a function is called.

The Call Stack: Javascript's To-Do List (Synchronous Only!)

The Call Stack (often just called "the stack") is a Last-In, First-Out (LIFO) data structure that manages all the execution contexts created during the execution of a script.

Imagine a stack of plates πŸ€”πŸ’­:

  • When a function is called, its execution context is pushed onto the top of the stack.
  • When that function finishes executing, its context is popped off the stack.
  • Javascript always executes the code at the very top of the stack.

lets see this in action:

function third() {
  console.log("Hello from third!");
}
function second() {
  third(); // Call third()
  console.log("Hello from second!");
}
function first() {
  second(); // Call second()
  console.log("Hello from first!");
}
first(); // Start the chain
console.log("End of script");

Here's how the Call Stack would evolve:

EC -> Execution Context

  1. Global EC pushed.
  2. first() called, first() EC pushed.
  3. second() called, second() EC pushed.
  4. third() called, third() EC pushed.
  5. console.log("Hello from third!") executes.
  6. third() finishes, third() EC popped.
  7. console.log("Hello from second!") executes.
  8. second() finishes, second() EC popped.
  9. console.log("Hello from first!") executes.
  10. first() finishes, first() EC popped.
  11. console.log("End of script") executes.
  12. Global script finishes, Global EC popped.

Important: Javascript is a single-threaded language. This means it can only do one thing at a time. If a function on the Call Stack takes a long time to execute, it "blocks" everything else. The browser becomes unresponsive, animations freeze, and UI updates stop. This is where our Task Queues 😎 come in!

The Event Loop and Task Queues: The Asynchronous Managers πŸ‘¨β€πŸ’Ό

To overcome the single-threaded limitation and prevent blocking, Javascript uses the Event Loop in together with two primary types of

Task Queues:

  1. Macrotask Queue (or simply Task Queue): This is where browser events (like click, load), setTimeout, setInterval, setImmediate (Node.js), and I/O operations are placed after they complete
  2. Microtask Queue: This is a higher-priority queue used for Promise callbacks (.then(), .catch(), .finally()), MutationObserver, and queueMicrotask

The Event Loop's Job: It's a constantly running process that monitors two things:

  1. The Call Stack: Is it empty?
  2. The Task Queues: Are there any pending tasks?

If the Call Stack is empty, the Event Loop takes a task from the queues and pushes it onto the Call Stack for execution.

Here's the crucial order of operations:

  1. Execute all synchronous code in the Global Execution Context until the Call Stack is empty.
  2. Once the Call Stack is empty, the Event Loop checks the Microtask Queue. It executes all microtasks, one by one, until the Microtask Queue is empty.
  3. After the Microtask Queue is empty, the Event Loop moves to the Macrotask Queue. It picks one macrotask from the queue and pushes it onto the Call Stack.
  4. Go back to step 1: Execute all synchronous code (which might include new microtasks or macrotasks) pushed by the current macrotask, clear the Microtask Queue, and then pick the next macrotask.

This cycle repeats indefinitely.

The Event Loop orchestrates the execution flow, ensuring responsiveness by intelligently prioritizing and scheduling tasks from different queues.

I will add image later

The "Why": Solving the Problem of Blocking

Imagine you click a button on a webpage, and that button's action involves fetching some data from a server. This network request can take hundreds of milliseconds, or even seconds, depending on internet speed.

If Javascript were purely synchronous, this is what would happen:

  1. Click event occurs.
  2. fetch() request is initiated.
  3. Javascript waits, doing absolutely nothing else,🫠 until the fetch() request completes.
  4. Only after the data arrives does Javascript continue executing.

During this waiting period, your webpage would be completely frozen! 🀯 You couldn't scroll, click other buttons, or see any animations. πŸ’© This is a terrible user experience.

Asynchronous Javascript to the Rescue! 🦸

This is why asynchronous operations and the Event Loop are critical. When you initiate an asynchronous task (like setTimeout, fetch, or clicking a button), Javascript doesn't wait for it. Instead:

  1. It tells the browser (or Node.js runtime) to handle that task. πŸ–₯️
  2. It immediately moves on to the next line of code. ▢️
  3. Once the asynchronous task completes (e.g., the setTimeout timer expires, the fetch request receives data, the button is clicked), its callback function is placed into the appropriate Task Queue. ⏳
  4. The Event Loop then picks up this callback when the Call Stack is empty and the higher-priority Microtask Queue is clear. βœ…

This non-blocking behavior is the foundation of responsive web applications.

The "How": A Deep Dive into the Lifecycle and Order of Operations

lets illustrate the full lifecycle with an example that brings together synchronous code, macrotasks, and microtasks.

Code Example: The Asynchronous Dance πŸ˜ƒ

console.log("1. Start of script");

setTimeout(() => {
  console.log("5. setTimeout callback (Macrotask)");
  Promise.resolve().then(() => {
    console.log("6. Promise.resolve inside setTimeout (Microtask)");
  });
}, 0); // schedule for 0ms (but still a macrotask!)

Promise.resolve().then(() => {
  console.log("3. Promise.resolve (Microtask)");
  setTimeout(() => {
    console.log("7. setTimeout inside Promise (Macrotask)");
  }, 0);
});

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

console.log("2. End of script");

What will be the output? lets trace it step-by-step.

Initial State:

  • Call Stack: Empty
  • Microtask Queue: Empty
  • Macrotask Queue: Empty

Execution Order Breakdown: 🧐

Phase 1: Synchronous Code Execution

  1. console.log("1. Start of script");
    • Output: 1. Start of script πŸ–¨οΈ
    • Call Stack: [console.log, Global EC] -> [Global EC]
  2. setTimeout(() => { ... }, 0);
    • The setTimeout function is called. The browser's Web APIs (or Node.js's C++ bindings) handle the timer. Even with 0ms, its callback is placed into the Macrotask Queue after the timer expires. ⏳
    • Call Stack: [setTimeout, Global EC] -> [Global EC]
    • Macrotask Queue: [setTimeout callback]
  3. Promise.resolve().then(() => { ... });
    • A resolved promise's .then() callback is immediately placed into the Microtask Queue. πŸ”„
    • Call Stack: [Promise.resolve, Global EC] -> [Global EC]
    • Microtask Queue: [Promise 1 callback]
  4. Promise.resolve().then(() => { ... });
    • Another resolved promise's .then() callback is immediately placed into the Microtask Queue. πŸ”„
    • Call Stack: [Promise.resolve, Global EC] -> [Global EC]
    • Microtask Queue: [Promise 1 callback, Promise 2 callback]
  5. console.log("2. End of script");
    • Output: 2. End of script πŸ–¨οΈ
    • Call Stack: [console.log, Global EC] -> [Global EC]

At this point, the Call Stack is empty. The Event Loop springs into action!

image I add later πŸ₯² I dont have one

Now, lets continue with the next phase of the execution.

Phase 2: Microtask Queue Execution (First Pass)

  1. Event Loop sees the Call Stack is empty. It checks the Microtask Queue, which is not empty. It takes the first microtask (Promise 1 callback) and pushes it onto the Call Stack. πŸ”„
  2. console.log("3. Promise.resolve (Microtask)"); inside Promise 1 callback executes.
    • Output: 3. Promise.resolve (Microtask) πŸ–¨οΈ
    • Call Stack: [console.log, Promise 1 callback EC, Global EC] -> [Promise 1 callback EC, Global EC]
  3. setTimeout(() => { ... }, 0); inside Promise 1 callback is called. Its callback is scheduled and placed into the Macrotask Queue. ⏳
    • Call Stack: [setTimeout, Promise 1 callback EC, Global EC] -> [Promise 1 callback EC, Global EC]
    • Macrotask Queue: [setTimeout callback (from script), setTimeout inside Promise callback]
  4. Promise 1 callback finishes. It's popped from the Call Stack.
    • Call Stack: [Global EC]
  5. Event Loop checks the Microtask Queue again. It's still not empty. It takes the next microtask (Promise 2 callback) and pushes it onto the Call Stack.
  6. console.log("4. Another Promise.resolve (Microtask)"); inside Promise 2 callback executes.
    • Output: 4. Another Promise.resolve (Microtask) πŸ–¨οΈ
    • Call Stack: [console.log, Promise 2 callback EC, Global EC] -> [Promise 2 callback EC, Global EC]
  7. Promise 2 callback finishes. It's popped from the Call Stack.
    • Call Stack: [Global EC]

At this point, the Microtask Queue is empty. βœ…


Phase 3: Macrotask Queue Execution (First Macrotask)

  1. Event Loop sees the Call Stack is empty and the Microtask Queue is empty. It checks the Macrotask Queue, which is not empty. It takes one macrotask (setTimeout callback from script) and pushes it onto the Call Stack.
  2. console.log("5. setTimeout callback (Macrotask)"); inside setTimeout callback executes.
    • Output: 5. setTimeout callback (Macrotask) πŸ–¨οΈ
    • Call Stack: [console.log, setTimeout callback EC, Global EC] -> [setTimeout callback EC, Global EC]
  3. Promise.resolve().then(() => { ... }); inside setTimeout callback is called. Its callback is immediately placed into the Microtask Queue. πŸ”„
    • Call Stack: [Promise.resolve, setTimeout callback EC, Global EC] -> [setTimeout callback EC, Global EC]
    • Microtask Queue: [Promise inside setTimeout callback]
  4. setTimeout callback finishes. It's popped from the Call Stack.
    • Call Stack: [Global EC]

At this point, the Call Stack is empty. The Event Loop checks for microtasks again! (Microtasks always get priority after each macrotask is processed).


Phase 4: Microtask Queue Execution (Second Pass)

  1. Event Loop sees the Call Stack is empty. It checks the Microtask Queue, which is not empty. It takes the Promise inside setTimeout callback and pushes it onto the Call Stack.
  2. console.log("6. Promise.resolve inside setTimeout (Microtask)"); executes.
    • Output: 6. Promise.resolve inside setTimeout (Microtask) πŸ–¨οΈ
    • Call Stack: [console.log, Promise inside setTimeout EC, Global EC] -> [Promise inside setTimeout EC, Global EC]
  3. Promise inside setTimeout callback finishes. It's popped from the Call Stack.
    • Call Stack: [Global EC]

At this point, the Microtask Queue is empty. βœ…


Phase 5: Macrotask Queue Execution (Second Macrotask)

  1. Event Loop sees the Call Stack is empty and the Microtask Queue is empty. It checks the Macrotask Queue, which is not empty. It takes the next macrotask (setTimeout inside Promise callback) and pushes it onto the Call Stack.
  2. console.log("7. setTimeout inside Promise (Macrotask)"); executes.
    • Output: 7. setTimeout inside Promise (Macrotask) πŸ–¨οΈ
    • Call Stack: [console.log, setTimeout inside Promise EC, Global EC] -> [setTimeout inside Promise EC, Global EC]
  3. setTimeout inside Promise callback finishes. It's popped from the Call Stack.
    • Call Stack: [Global EC]

Final Output:

1. Start of script
2. End of script
3. Promise.resolve (Microtask)
4. Another Promise.resolve (Microtask)
5. setTimeout callback (Macrotask)
6. Promise.resolve inside setTimeout (Microtask)
7. setTimeout inside Promise (Macrotask)

This sequence demonstrates the critical priority of microtasks over macrotasks, and how the Event Loop repeatedly drains the microtask queue after each macrotask and after the initial synchronous execution.

Loops, Hold, and Release: Synchronous vs. Asynchronous Iteration

When discussing loops, it's essential to distinguish between synchronous loops and patterns that appear like loops but leverage asynchronous mechanisms.

Synchronous Loops (e.g., for, while, forEach):

These loops execute entirely on the Call Stack. If a for loop iterates a million times, it will block the main thread until all million iterations are complete.

  • Hold: The loop holds the execution thread entirely until it finishes.
  • Release: The thread is only released once the loop completes. No asynchronous tasks (even those ready in a queue) can jump in.
console.log("Loop start");
for (let i = 0; i < 1000000000; i++) {
 // this loop runs for a long time
}
console.log("Loop end");
// any setTimeout scheduled before this loop will only run AFTER "Loop end" is printed.

Asynchronous Loops and Iteration (e.g., setInterval, async/await with for...of):

When you use setInterval or async/await within a loop, you're not creating a single, blocking loop. Instead, you're scheduling individual tasks or making each iteration potentially wait for an asynchronous operation.

  • setInterval: Each callback fired by setInterval is a separate macrotask. The browser executes other tasks between these intervals.
let count = 0;
const intervalId = setInterval(() => {
  console.log(`Interval tick: ${count++}`);
  if (count === 3) {
    clearInterval(intervalId);
  }
}, 1000);
console.log("Interval started, script continues...");

Output:

Interval started, script continues...
(after 1s) Interval tick: 0
(after 1s) Interval tick: 1
(after 1s) Interval tick: 2

Here, the setInterval callback is placed in the macrotask queue every second. Between each tick, the Event Loop is free to process other events or render UI updates.

  • async/await with for...of: This pattern allows you to iterate over asynchronous operations sequentially without blocking the entire thread.
async function processItems(items) {
  console.log("Processing items start");
  for (const item of items) {
    await new Promise((resolve) => setTimeout(resolve, 500)); // simulate async work
    console.log(`Processed: ${item}`);
  }
  console.log("Processing items end");
}

processItems(["A", "B", "C"]);
console.log("Main script continues after calling processItems");

Output:

Processing items start
Main script continues after calling processItems
(after 0.5s) Processed: A
(after 0.5s) Processed: B
(after 0.5s) Processed: C
Processing items end

In this async function, await pauses the execution of the async function itself, but it releases the Event Loop to handle other tasks while waiting for the promise to resolve.

Once the promise resolves (i.e., the setTimeout finishes), the async function's execution resumes as a microtask. This is a powerful non-blocking pattern.

Intuition: The Kitchen Analogy

Imagine a busy kitchen where a single chef (Javascript's single thread) is preparing a meal.

  • The Chef's Hands (Call Stack): πŸ§‘β€πŸ³ The chef can only do one thing at a time with their hands – chop vegetables πŸ…, stir a pot 🍲, flip a pancake πŸ₯˜. whatever they are doing right now is on their hands (Call Stack).
  • The Immediate Order List (Microtask Queue): πŸ“ This is a small notepad right next to the chef. If someone asks for a quick, urgent task (like "taste this sauce!") that doesn't take long, the chef will finish whatever is currently in their hands, then immediately check this notepad and do ALL those urgent tasks before moving to anything else.
  • The Long Order List (Macrotask Queue): ◻️ This is a larger whiteboard further away. It has bigger tasks like "bake a cake," "boil water," or "wait for the oven to preheat." These tasks take a while, or involve waiting for something else (like an oven) ⏲️.
  • The Kitchen Manager (Event Loop): πŸ‘€ This person's job is to watch the chef.
    • If the chef's hands are busy, the manager waits. 🫷
    • If the chef's hands are free, the manager first checks πŸ“ the Immediate Order List. If There is anything there, they give all those tasks to the chef until the notepad is empty.
    • Once the Immediate Order List is empty, then the manager checks the Long Order List. They pick _just one_ 1️⃣ big task from that list and give it to the chef.
    • After the chef finishes that one big task, the manager again checks the Immediate Order List πŸ‘€ (because that big task might have created some urgent little tasks!), then the Long Order List again.

This constant checking and prioritizing ensures that the kitchen (your browser) never completely freezes, and urgent small tasks get handled quickly, even if There is a big cake baking! 🦸

Further Reading: