Understanding the JavaScript Event Loop

Demystifying Call Stack, Web APIs, Callback Queue, Microtasks, and more.
JavaScript is a single-threaded language, which means it can only do one thing at a time. But if that’s true, how can it handle things like timeouts, network requests, and user interactions without freezing?
The answer lies in the Event Loop — the behind-the-scenes hero of JavaScript's concurrency model.
In this article, we’ll break it down step by step with examples and visuals.
The Browser Environment
Your JavaScript code doesn’t run in isolation — it runs inside the browser, which provides:
The JavaScript Engine (like V8 in Chrome)
Access to things like:
setTimeout
fetch
DOM APIs
console.log
localStorage
These are not part of JavaScript itself — they’re provided by the Web APIs built into the browser.
JavaScript Engine & Call Stack
At the core of the JS engine is the Call Stack — a stack data structure where your code executes one function at a time (top-down).
console.log("Start");
setTimeout(() => console.log("Callback"), 2000);
console.log("End");
Visual Execution Flow:
console.log("Start")
→ added to the call stack → executedsetTimeout(...)
→ handled by Web API (browser timer)console.log("End")
→ executedAfter 2000ms → callback function is pushed to Callback Queue
Event Loop moves it to the call stack when it’s empty
Output:
Start
End
Callback
What is the Event Loop?
The Event Loop constantly checks if the Call Stack is empty and if there are functions in the Callback Queue or Microtask Queue waiting to run.
Event Listeners (DOM Events)
When the JS engine encounters an event listener like a click handler:
<button id="btn">Click Me</button>
<script>
document.getElementById("btn").addEventListener("click", () => {
console.log("Button Clicked");
});
</script>
What Happens?
addEventListener
registers a callback with the Web API (DOM event system).When the user clicks, the event is captured and the callback is moved to the Callback Queue.
When the call stack is empty, the event loop pushes it onto the stack and it’s executed.
fetch()
and Promises
Unlike setTimeout
, fetch()
uses Promises and the Microtask Queue, which has higher priority than the callback queue.
console.log("Start");
setTimeout(() => console.log("Timeout"), 0);
fetch("https://jsonplaceholder.typicode.com/posts/1")
.then(() => console.log("Fetch resolved"));
console.log("End");
Execution Order:
"Start"
→ Call StacksetTimeout
→ Web API → Callback Queuefetch()
→ Web API → Microtask Queue (after resolution)"End"
→ Call StackEvent loop checks:
Microtask queue (→
"Fetch resolved"
)Then Callback Queue (→
"Timeout"
)
Output:
Start
End
Fetch resolved
Timeout
Promises vs Callback Queue
Feature | Queue |
setTimeout | Callback Queue |
fetch().then() | Microtask Queue |
Promise.then | Microtask Queue |
addEventListener | Callback Queue |
MutationObserver | Microtask Queue |
MutationObserver
The MutationObserver
is a browser-provided API lets you watch for changes in the DOM and react to them asynchronously. This is especially useful when:
Elements are added, removed, or changed dynamically.
You want to perform actions whenever DOM updates happen (e.g., animations, logging, lazy loading, etc.).
You want to avoid expensive polling techniques (like
setInterval
).
It replaces older APIs like Mutation Events
, which were inefficient and caused performance issues.
How it Works
You create a
MutationObserver
instance by passing it a callback.You then tell it what DOM node to observe and what types of mutations to listen for.
When those mutations occur, your callback is queued in the microtask queue and runs after the current execution and all previously queued microtasks.
Syntax
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);
Parameters:
callback
: A function called when mutations are observed.targetNode
: The DOM element to observe.config
: An object specifying which mutations to observe.
Example: Observing Child Changes
<div id="app">
<p>Hello!</p>
</div>
const targetNode = document.getElementById("app");
const config = {
childList: true, // watch for additions/removals of child nodes
subtree: true // watch child nodes of child nodes
};
const callback = (mutationList, observer) => {
for (let mutation of mutationList) {
if (mutation.type === "childList") {
console.log("A child node has been added or removed.");
}
}
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);
// This will trigger the observer:
targetNode.appendChild(document.createElement("span"));
What Can You Observe?
You can observe changes in:
Type | Description |
childList | Add/remove child elements |
attributes | Attribute changes (e.g., class, id) |
subtree | Changes to all descendants of the node |
characterData | Text content changes (e.g., .textContent ) |
Key Points
Non-blocking: Runs in the microtask queue — after the current code and before
setTimeout
callbacks.Efficient: Better performance than polling or mutation events.
Disconnect: Always disconnect the observer when you're done to avoid memory leaks:
observer.disconnect();
When to Use
Use MutationObserver
when:
You're working with dynamic UIs (e.g., third-party DOM injections).
You want to detect content loads that happen outside your control.
You’re tracking layout changes for metrics or automation.
Task Starvation
Task starvation happens when macrotasks (from the callback queue) — like setTimeout
, setInterval
, or event listeners — are delayed indefinitely because the microtask queue (used by Promises and MutationObserver
) keeps getting filled up without giving control back to the macrotasks.
In simpler terms:
The event loop never gets to the callback queue because it’s always busy processing more microtasks.
Why Does It Happen?
This is because:
Microtasks (from the microtask queue) are always executed before the next macrotask.
If each microtask adds more microtasks, the event loop stays trapped in the microtask phase.
Example of Task Starvation:
function infiniteMicrotasks() {
Promise.resolve().then(() => {
console.log("Microtask");
infiniteMicrotasks(); // recursion creates more microtasks
});
}
setTimeout(() => {
console.log("Macrotask (setTimeout)");
}, 0);
infiniteMicrotasks();
Output:
Microtask
Microtask
Microtask
...
In this case, setTimeout
never runs. The macrotask is starved because the microtask queue never empties.
Is There a Way to Escape Task Starvation?
Yes. Here are 3 common strategies:
1. Avoid Uncontrolled Microtask Recursion
Avoid writing recursive microtasks (like in the example above) unless you break them up with a setTimeout
or similar.
Fix with setTimeout
:
function balancedMicrotasks(count = 0) {
if (count > 1000) return;
Promise.resolve().then(() => {
console.log("Microtask", count);
setTimeout(() => balancedMicrotasks(count + 1), 0); // yield control
});
}
balancedMicrotasks();
This ensures the event loop has a chance to process the setTimeout
.
2. Use queueMicrotask
Wisely
Use queueMicrotask
for small, fast tasks only. Don’t abuse it for loops or heavy tasks.
queueMicrotask(() => {
console.log("Microtask - good for short logic");
});
3. Introduce setTimeout
or requestIdleCallback
to Yield Control
If you have a lot of work, chunk it up using setTimeout
, requestIdleCallback
, or requestAnimationFrame
to avoid freezing the event loop.
function chunkedTask(data) {
if (!data.length) return;
doWork(data.splice(0, 100)); // process 100 items
setTimeout(() => chunkedTask(data), 0); // yield to event loop
}
Summary
JavaScript may be single-threaded, but thanks to the Event Loop, it can handle asynchronous tasks efficiently using:
Web APIs (like
setTimeout
,fetch
)The Call Stack
Callback & Microtask Queues
Understanding how the Event Loop works helps you write more performant, bug-free JavaScript — especially when dealing with async code.
Thanks for reading! If you found this useful, consider sharing it with fellow developers or bookmarking it for later reference.
Subscribe to my newsletter
Read articles from Deepthi Purijala directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Deepthi Purijala
Deepthi Purijala
Full Stack Developer with hands-on experience of more than 1 year. Proficient in both Back-end and Front-end technologies, with a strong commitment to delivering high-quality code