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 PlaylistEnes 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:
- Variable Environment: Where variables (like var, let, const) and function declarations are stored for the current scope.
- Lexical Environment: This is closely related to the variable environment and determines how scope chaining works (how Javascript finds variables in outer scopes).
- 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
pushedonto the top of the stack. - When that function finishes executing, its context is
poppedoff the stack. - Javascript always
executesthe code at thevery topof thestack.
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
- Global EC
pushed. - first() called, first() EC
pushed. - second() called, second() EC
pushed. - third() called, third() EC
pushed. - console.log("Hello from third!")
executes. - third() finishes, third() EC
popped. - console.log("Hello from second!")
executes. - second() finishes, second() EC
popped. - console.log("Hello from first!")
executes. - first() finishes, first() EC
popped. - console.log("End of script")
executes. - 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:
- Macrotask Queue (or simply Task Queue): This is where
browser events(like click, load),setTimeout,setInterval,setImmediate(Node.js), andI/Ooperations are placed after they complete - Microtask Queue: This is a
higher-priorityqueue used forPromisecallbacks(.then(), .catch(), .finally()),MutationObserver, andqueueMicrotask
The Event Loop's Job: It's a constantly running process that monitors two things:
- The Call Stack: Is it empty?
- 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:
- Execute all synchronous code in the Global Execution Context until the Call Stack is empty.
- 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.
- 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.
- 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:
Clickevent occurs.fetch()request is initiated.- Javascript waits, doing absolutely nothing else,π« until the fetch() request completes.
- 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:
- It tells the
browser(orNode.js runtime) to handle that task. π₯οΈ - It immediately moves on to the next line of code. βΆοΈ
- Once the
asynchronous taskcompletes (e.g., thesetTimeouttimer expires, thefetchrequest receives data, thebuttonis clicked), its callback function is placed into the appropriate Task Queue. β³ - The
Event Loopthen 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
console.log("1. Start of script");- Output: 1. Start of script π¨οΈ
- Call Stack:
[console.log, Global EC] -> [Global EC]
setTimeout(() => { ... }, 0);- The
setTimeoutfunction is called. The browser'sWeb 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]
- The
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]
- A resolved promise's
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]
- Another resolved promise's
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)
Event Loopsees 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. πconsole.log("3. Promise.resolve (Microtask)"); insidePromise 1 callbackexecutes.- Output: 3. Promise.resolve (Microtask) π¨οΈ
- Call Stack:
[console.log, Promise 1 callback EC, Global EC] -> [Promise 1 callback EC, Global EC]
setTimeout(() => { ... }, 0); insidePromise 1 callbackis 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]
- Call Stack:
Promise 1 callbackfinishes. It's popped from the Call Stack.- Call Stack:
[Global EC]
- Call Stack:
Event Loopchecks the Microtask Queue again. It's still not empty. It takes the next microtask (Promise 2 callback) and pushes it onto the Call Stack.console.log("4. Another Promise.resolve (Microtask)"); insidePromise 2 callbackexecutes.- Output: 4. Another Promise.resolve (Microtask) π¨οΈ
- Call Stack:
[console.log, Promise 2 callback EC, Global EC] -> [Promise 2 callback EC, Global EC]
Promise 2 callbackfinishes. It's popped from the Call Stack.- Call Stack:
[Global EC]
- Call Stack:
At this point, the Microtask Queue is empty. β
Phase 3: Macrotask Queue Execution (First Macrotask)
Event Loopsees 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.console.log("5. setTimeout callback (Macrotask)"); insidesetTimeout callbackexecutes.- Output: 5. setTimeout callback (Macrotask) π¨οΈ
- Call Stack:
[console.log, setTimeout callback EC, Global EC] -> [setTimeout callback EC, Global EC]
Promise.resolve().then(() => { ... }); insidesetTimeout callbackis 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]
- Call Stack:
setTimeout callbackfinishes. It's popped from the Call Stack.- Call Stack:
[Global EC]
- Call Stack:
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)
Event Loopsees the Call Stack is empty. It checks the Microtask Queue, which is not empty. It takes thePromise inside setTimeout callbackand pushes it onto the Call Stack.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]
Promise inside setTimeout callbackfinishes. It's popped from the Call Stack.- Call Stack:
[Global EC]
- Call Stack:
At this point, the Microtask Queue is empty. β
Phase 5: Macrotask Queue Execution (Second Macrotask)
Event Loopsees 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.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]
setTimeout inside Promise callbackfinishes. It's popped from the Call Stack.- Call Stack:
[Global EC]
- Call Stack:
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
holdstheexecution threadentirely until itfinishes. - Release: The thread is only
releasedonce theloop 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
setIntervalis 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 theirhands(Call Stack). - The Immediate Order List (
Microtask Queue): π This is a small notepad right next to the chef. If someone asks for aquick,urgent task(like "taste this sauce!") thatdoesn't take long, the chef will finish whatever is currently in their hands, then immediately check this notepad and do ALL those urgent tasksbeforemovingto anything else. - The Long Order List (
Macrotask Queue): β»οΈ This is a larger whiteboardfurther away. It hasbigger taskslike "bake a cake," "boil water," or "wait for the oven to preheat." These tasks take a while, orinvolve waitingfor 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 Listisempty, then the managerchecksthe Long Order List. Theypick_just one_1οΈβ£big taskfrom that list and give it to the chef. Afterthe cheffinishesthat onebig task, the manageragainchecks 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:
- Philip Roberts: What the heck is the event loop anyway? (A classic, highly recommended talk)
- MDN Web Docs: Concurrency model and Event Loop
- Tasks, microtasks, queues and schedules (A very thorough breakdown)
- Event loops: microtasks & macrotasks (it's a good one)
