The single-threaded constraint
The JavaScript engine has a single call stack. It processes one function call at a time, from top to bottom. If a function takes a long time to run — say, a heavy loop or a synchronous network request — nothing else can happen during that time. The browser cannot repaint, user events are ignored, and the page appears frozen. This is why JavaScript relies on asynchronous, non-blocking APIs. Instead of waiting for an operation to complete, you register a callback and let the runtime invoke it when the result is ready.The components
Before tracing the event loop, it helps to name the pieces involved:| Component | Role |
|---|---|
| Call stack | Executes code — one frame at a time |
| Web APIs | Browser-provided async capabilities (timers, fetch, DOM events, etc.) |
| Callback queue (task queue) | Holds callbacks from completed Web API operations |
| Microtask queue | Holds promise callbacks and queueMicrotask callbacks |
| Event loop | Moves items from queues to the stack when the stack is empty |
The event loop cycle
Execute all synchronous code
The engine runs the current script from top to bottom. Every function call is pushed onto the call stack and popped when it returns. Web API calls (like
setTimeout or fetch) are handed off to the browser immediately and return right away — they do not block the stack.Drain the microtask queue
Once the call stack is empty, the event loop checks the microtask queue first. It runs every microtask currently in the queue. If a microtask adds more microtasks, those are also run before moving on. The microtask queue is fully drained before any macro-tasks execute.
Run one macro-task from the callback queue
If the microtask queue is empty, the event loop takes the oldest macro-task from the callback queue (a
setTimeout callback, a setInterval tick, a DOM event handler, etc.) and pushes it onto the call stack.Drain the microtask queue again
After each macro-task completes, the microtask queue is drained again before the next macro-task is started.
Render (if in a browser)
The browser may perform a rendering update (layout, paint) between event loop iterations, typically targeting 60 frames per second. Long-running tasks that block the loop will delay repaints and cause visible jank.
The terms “macro-task” and “task” are used interchangeably. The HTML specification uses “task” — the “macro” prefix is an informal way to distinguish tasks in the task queue from microtasks.
A concrete example
console.log("1 …")runs synchronously.setTimeoutis called. The callback is handed to the browser’s timer API, which will enqueue it as a macro-task after ~0 ms.Promise.resolve().then(microtask1)creates a resolved promise and schedulesmicrotask1on the microtask queue immediately.console.log("2 …")runs synchronously.- The call stack is now empty. The event loop checks the microtask queue:
microtask1runs. microtask1’s.then(microtask2)schedulesmicrotask2on the microtask queue.microtask2runs (microtask queue is drained completely).- The microtask queue is empty. The event loop picks the macro-task:
macroTaskruns, printing"4 …".
setTimeout delay is a minimum, not a guarantee
while loop finishes blocking the call stack. The actual delay will be at least 500 ms. This illustrates why long synchronous operations (“blocking the event loop”) harm responsiveness.
Microtasks vs macro-tasks
The microtask queue exists to give certain callbacks higher priority. Promise callbacks (then, catch, finally) and queueMicrotask all post microtasks. The rationale is that promise continuations should run as soon as possible after the current synchronous code, before any unrelated I/O or timers.
microtask C is enqueued from within a microtask, but it still runs before the macro-task because the microtask queue is fully drained after each task — including microtasks added during draining.
async/await and the event loop
async/await is syntactic sugar over promises. An await expression suspends the current async function and schedules its resumption as a microtask once the awaited promise settles.
await is hit, fetchData is suspended. Control returns to the caller. "B" is logged synchronously. Then the microtask queue runs and resumes fetchData after "B".