Featured image of post JavaScript Event Loop Deep Dive: Microtasks, Macrotasks, and Beyond

JavaScript Event Loop Deep Dive: Microtasks, Macrotasks, and Beyond

Deep dive into the JavaScript event loop covering call stack, microtask/macrotask queues, requestAnimationFrame, rendering pipeline, async/await internals, and performance implications.

The JavaScript event loop is one of the most important yet misunderstood concepts in the language. Despite JavaScript being single-threaded, its event loop enables non-blocking concurrency through a sophisticated queue system. This article builds a complete mental model of how JavaScript handles asynchronous execution, from the call stack to the microtask queue and everything in between.

The Call Stack and Execution Model

JavaScript uses a call stack with a LIFO (Last In, First Out) structure. When a function is called, a new stack frame is pushed onto the stack. When the function returns, the frame is popped off. The key principle is run-to-completion: each function runs to completion before any other code can interrupt it.

function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function logSquare(x) { console.log(square(x)); }

logSquare(4); // call stack: logSquare → square → multiply → ...

This single-threaded model means long-running operations block the main thread. The event loop solves this by delegating waiting operations to the runtime environment (browser or Node.js) and processing their callbacks asynchronously through specialized queues.


Macrotask Queue

Macrotasks, also called tasks, originate from setTimeout, setInterval, I/O events, UI events, and setImmediate in Node.js. The event loop picks one macrotask from the queue in each iteration, executes it to completion, then processes the microtask queue before moving to the next macrotask.

console.log("Start");
setTimeout(() => console.log("Timeout"), 0);
console.log("End");
// Output: Start, End, Timeout

Even with a delay of 0, the setTimeout callback is queued as a macrotask and executes only after the current synchronous code and all microtasks have completed. This behavior is frequently misunderstood by developers who expect setTimeout(fn, 0) to execute immediately.


Microtask Queue

Microtasks have higher priority than macrotasks. They originate from Promise .then(), .catch(), .finally(), queueMicrotask(), MutationObserver, and process.nextTick() in Node.js. The critical rule is that the entire microtask queue is drained before the next macrotask is processed.

console.log("1");
Promise.resolve().then(() => console.log("2"));
setTimeout(() => console.log("3"), 0);
console.log("4");
// Output: 1, 4, 2, 3

This ordering explains why Promises resolve before setTimeout callbacks, even when both are ready. A dangerous consequence is microtask starvation: recursively scheduling microtasks can prevent macrotasks from ever executing, freezing the application.

Queue TypeSourcesPriorityDrained Per Cycle
MacrotasksetTimeout, I/O, UI eventsLowerOne task
MicrotaskPromise, queueMicrotaskHigherEntire queue
RenderingrequestAnimationFrameBetweenBefore paint

Async/Await Internals

The async and await keywords are syntactic sugar over Promises and generator functions. An async function executes synchronously up to the first await expression, at which point it yields control back to the event loop and resumes when the awaited Promise resolves as a microtask.

async function example() {
  console.log("A");
  await Promise.resolve();
  console.log("B");
}
example();
console.log("C");
// Output: A, C, B

The await keyword does not block the thread. It desugars into a Promise .then() callback, which is queued as a microtask. This is why code after await executes after all synchronous code has completed.


requestAnimationFrame and Rendering

The rendering pipeline—style recalculation, layout, and paint—occurs at specific points in the event loop cycle. requestAnimationFrame callbacks run before the browser performs a paint, making them the correct API for visual updates.

function animate(timestamp) {
  element.style.transform = `translateX(${Math.sin(timestamp / 1000) * 100}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Using setTimeout or setInterval for animations causes jank because their callbacks are not aligned with the browser’s paint cycle. The requestIdleCallback API, on the other hand, is designed for non-critical work that should not impact frame budget.


Performance Implications

Long tasks—JavaScript execution exceeding 50 milliseconds—block the main thread and cause visible jank. The Long Tasks API enables programmatic monitoring:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("Long task detected:", entry.duration, "ms");
  }
});
observer.observe({ entryTypes: ["longtask"] });

Long tasks directly impact Core Web Vitals, particularly Interaction to Next Paint (INP). Mitigation strategies include breaking up long tasks with setTimeout or scheduler.yield(), offloading heavy computation to Web Workers, and using requestIdleCallback for deferrable work.


Conclusion

Mastering the event loop is essential for writing performant JavaScript. Understanding the relationship between the call stack, macrotask queue, microtask queue, and rendering pipeline helps you avoid common bugs, optimize animation performance, and diagnose production issues. The mental model of run-to-completion execution with prioritized queue draining is the foundation for all asynchronous JavaScript development.