Here is a breakdown of how JavaScript handles asynchronous operations under the hood.
The Core Actors
The Event Loop is a continuous process that moves callbacks between four main areas:
- The Call Stack: Where your synchronous JavaScript code executes. Functions are pushed onto the stack when called and popped off when they return.
- Web APIs / Node APIs: Features provided by the browser (like the DOM or
fetch) or Node.js (likefsorcrypto) that run tasks in the background. - The Microtask Queue: A high-priority queue for critical, immediate follow-up work.
- The Macrotask Queue (Task Queue): A lower-priority queue for deferred work like timers and UI events.
Microtasks vs. Macrotasks
The most common source of asynchronous bugs is misunderstanding the priority difference between these two queues.
Whenever the Call Stack empties, the Event Loop follows a strict rule: Drain the Microtask Queue completely before touching the Macrotask Queue.
- Microtasks: Generated by
Promise.then(),queueMicrotask(), andMutationObserver. If a microtask schedules another microtask, the Event Loop will keep processing them until the queue is entirely empty. If you aren’t careful, an infinite loop of microtasks will stall your application. - Macrotasks: Generated by timers (
setTimeout,setInterval), network I/O, and UI events (clicks, scrolls). The Event Loop typically processes exactly one macrotask per cycle before checking the microtask queue again and yielding to the browser’s rendering engine.
The Reality of Timers
A common misconception is that setTimeout(callback, 1000) guarantees the callback will run exactly 1,000 milliseconds from now.
In reality, timers dictate the minimum delay. After 1,000ms, the Web API pushes your callback into the Macrotask Queue. However, if the Call Stack is busy, or if the Microtask Queue is flooded with Promises, your timer callback has to wait in line. This is why setTimeout(..., 0) doesn’t execute instantly—it simply queues the task to run at the next available opportunity.
Modern Async Patterns
Today, async and await are the standard patterns for handling asynchronous code, providing a cleaner alternative to nested Promise chains (the infamous “callback hell”).
Under the hood, async/await is syntactic sugar over Promises. When the engine encounters an await keyword, it suspends the execution of that specific function and yields control back to the main thread. The remainder of the function is then scheduled as a Microtask to be executed once the awaited Promise resolves.