The Event Loop, Microtask Queue & Execution Phases
Step inside the asynchronous heartbeat of JavaScript. Trace execution order across the Call Stack, Microtask Queue, and Task Queue (Macrotasks).
By the end, you’ll be able to…
- Visualize the event loop coordinator moving tasks between queues and the single call stack.
- Differentiate between Microtasks (Promises, queueMicrotask, MutationObserver) and Tasks (setTimeout, setInterval, I/O).
- Predict precise execution order across complex synchronous and asynchronous code blocks.
01The Single-Threaded Illusion: How Asynchronous JS Works
JavaScript is single-threaded: the engine has only one call stack and can only execute one line of JavaScript at any single instant. How then does a browser or Node.js runtime handle network requests, mouse clicks, database queries, and timers without freezing the entire application?
The answer lies in the host environment and the Event Loop. While V8 executes your JavaScript code, the host environment (the browser or Node.js C++ runtime) provides Web APIs and background threads. When you invoke setTimeout or fetch(), the engine hands the operation off to the host environment and immediately continues executing synchronous code on the call stack.
02The Two Queues: Microtasks vs Tasks (Macrotasks)
When asynchronous work completes in the host environment, its callback does NOT immediately interrupt the call stack. Instead, callbacks wait in queues. Crucially, there are two distinct queues with strict priority hierarchy: the Microtask Queue and the Task Queue (formerly Macrotask Queue).
Tasks include setTimeout, setInterval, setImmediate, and I/O events. Microtasks include Promise resolution handlers (.then, .catch, .finally), queueMicrotask(), and DOM MutationObserver callbacks.
The golden rule of the event loop is: After the current synchronous call stack empties, the engine MUST drain the ENTIRE Microtask Queue before it picks a single task from the Task Queue. If a microtask schedules another microtask, that new microtask runs in the same tick!
03The Rendering Loop and Microtask Starvation
In web browsers, the event loop also coordinates UI rendering (style calculation, layout, painting), which typically occurs at 60Hz or 120Hz (every 16.6ms or 8.3ms). Rendering can only take place when the call stack is completely idle.
Because the engine drains all microtasks before checking the rendering pipeline or picking the next task, creating an infinite loop of microtasks (e.g. recursive Promise.resolve().then()) completely starves the event loop. The UI freezes, user clicks stop responding, and the browser tab crashes.
04Why This Matters in Production
Diagnosing production latency and UI jank requires precise mental modeling of the event loop. When breaking up long-running computational tasks (such as parsing huge JSON files or rendering virtual tables), developers often mistakenly use Promise.resolve().then() to yield control.
Because microtasks drain immediately before rendering, this does NOT yield to the UI renderer. To allow the browser to paint a frame or process a user click, you must yield to the Task Queue using setTimeout(fn, 0) or the modern scheduler.yield() Web API.
The ideas worth keeping.
- Synchronous code runs first, microtasks run second (all of them), and macrotasks run third.
- Promises and queueMicrotask schedule microtasks; setTimeout and setInterval schedule macrotasks.
- To yield control to the browser's rendering engine during heavy processing, use macrotask delays (setTimeout or scheduler.yield).
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Exercise 1: Event Loop Trace Challenge
Write a function that schedules one synchronous log, one microtask with queueMicrotask, and one task with setTimeout, returning an array verifying their execution sequence.
Explore a worked solution
function verifyExecutionOrder() { const log = []; log.push('sync'); setTimeout(() => log.push('task'), 0); queueMicrotask(() => log.push('microtask')); return log; // At this instant, ['sync']}Synchronous code pushes 'sync' first. After call stack empties, microtask runs next, followed finally by the timer task.
Exercise 2: Yielding to the Browser Renderer
Implement a utility yieldToMain() returning a Promise that resolves on the Task Queue using setTimeout(resolve, 0), allowing UI renders to occur.
Explore a worked solution
function yieldToMain() { return new Promise(resolve => setTimeout(resolve, 0));}Wrapping setTimeout in a Promise pushes the resumption of an async function onto the Task Queue, giving the browser opportunity to recalculate styles and paint.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Exercise 1: Event Loop Trace Challenge
Write a function that schedules one synchronous log, one microtask with queueMicrotask, and one task with setTimeout, returning an array verifying their execution sequence.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this lesson, when you want a second angle or the full details.
One lesson closer.
Reading is a start. Trying it yourself makes the connection.