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:

  1. console.log("Start") → added to the call stack → executed

  2. setTimeout(...) → handled by Web API (browser timer)

  3. console.log("End") → executed

  4. After 2000ms → callback function is pushed to Callback Queue

  5. 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?

  1. addEventListener registers a callback with the Web API (DOM event system).

  2. When the user clicks, the event is captured and the callback is moved to the Callback Queue.

  3. 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 Stack

  • setTimeout → Web API → Callback Queue

  • fetch() → Web API → Microtask Queue (after resolution)

  • "End" → Call Stack

  • Event loop checks:

    • Microtask queue (→ "Fetch resolved")

    • Then Callback Queue (→ "Timeout")

Output:

Start
End
Fetch resolved
Timeout

Promises vs Callback Queue

FeatureQueue
setTimeoutCallback Queue
fetch().then()Microtask Queue
Promise.thenMicrotask Queue
addEventListenerCallback Queue
MutationObserverMicrotask 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:

TypeDescription
childListAdd/remove child elements
attributesAttribute changes (e.g., class, id)
subtreeChanges to all descendants of the node
characterDataText 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.

7
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