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
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 thevery top
of 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/O
operations are placed after they complete - Microtask Queue: This is a
higher-priority
queue used forPromise
callbacks
(.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:
Click
event 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 task
completes (e.g., thesetTimeout
timer expires, thefetch
request receives data, thebutton
is clicked), its callback function is placed into the appropriate Task Queue. β³ - 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
console.log("1. Start of script")
;- Output: 1. Start of script π¨οΈ
- Call Stack:
[console.log, Global EC] -> [Global EC]
setTimeout(() => { ... }, 0)
;- The
setTimeout
function 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 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. πconsole.log("3. Promise.resolve (Microtask)")
; insidePromise 1 callback
executes.- 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 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
]
- Call Stack:
Promise 1 callback
finishes. It's popped from the Call Stack.- Call Stack:
[Global EC]
- Call Stack:
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.console.log("4. Another Promise.resolve (Microtask)")
; insidePromise 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]
Promise 2 callback
finishes. 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 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.console.log("5. setTimeout callback (Macrotask)")
; insidesetTimeout callback
executes.- Output: 5. setTimeout callback (Macrotask) π¨οΈ
- Call Stack:
[console.log, setTimeout callback EC, Global EC] -> [setTimeout callback EC, Global EC]
Promise.resolve().then(() => { ... })
; insidesetTimeout 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
]
- Call Stack:
setTimeout callback
finishes. 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 Loop
sees the Call Stack is empty. It checks the Microtask Queue, which is not empty. It takes thePromise inside setTimeout callback
and 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 callback
finishes. 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 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.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 callback
finishes. 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
holds
theexecution thread
entirely until itfinishes
. - Release: The thread is only
released
once 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
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 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 tasksbefore
moving
to anything else. - The Long Order List (
Macrotask Queue
): β»οΈ This is a larger whiteboardfurther away
. It hasbigger tasks
like "bake a cake," "boil water," or "wait for the oven to preheat." These tasks take a while, orinvolve 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
isempty
, then the managerchecks
the Long Order List. Theypick
_just one_
1οΈβ£big task
from that list and give it to the chef. After
the cheffinishes
that onebig task
, the manageragain
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:
- 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)