JavaScript is single-threaded — it has one call stack and can execute one piece of code at a time. Yet it handles thousands of concurrent operations (network requests, timers, user interactions) without blocking. How?
The answer is the event loop: a mechanism that coordinates the call stack, Web APIs, and task queues to give the illusion of concurrency. Understanding the event loop is fundamental to writing correct async JavaScript and appears frequently in technical assessments.
The Call Stack: One Thing at a Time
The call stack is a Last-In-First-Out (LIFO) data structure that tracks which function is currently executing. When you call a function, it is pushed onto the stack. When it returns, it is popped off.
Consider: function multiply(a, b) { return a * b; } function square(n) { return multiply(n, n); } console.log(square(5));
The stack evolves as: push main() → push console.log() → push square(5) → push multiply(5, 5) → multiply returns 25, pop → square returns 25, pop → console.log prints 25, pop → main ends, pop.
If any function takes a long time (e.g., a massive computation), the entire stack is blocked. No other code can run, the UI freezes, and event handlers stop responding. This is why long synchronous operations are problematic in JavaScript.
Web APIs: Offloading Work to the Browser
When you call setTimeout, fetch, or addEventListener, JavaScript does not execute these in the call stack. Instead, it hands them off to Web APIs provided by the browser (or the C++ layer in Node.js).
setTimeout(callback, 1000) tells the browser: 'Start a timer. After 1000ms, put this callback in the task queue.' The call to setTimeout itself returns immediately — it does not block the stack for 1 second.
Similarly, fetch(url) sends the HTTP request to the browser's networking layer. The JavaScript thread continues immediately. When the response arrives, the browser places the .then() callback in the microtask queue.
This is why JavaScript is 'non-blocking' despite being single-threaded: the actual waiting happens outside the JavaScript engine, in the browser's native code.
The Task Queue (Macrotask Queue)
When a Web API completes its work (timer expires, event fires), it places the callback into the task queue (also called the macrotask queue or callback queue).
The task queue is a First-In-First-Out (FIFO) structure. Callbacks are processed in the order they arrive. Common macrotask sources include: setTimeout, setInterval, I/O callbacks, UI rendering events, and MessageChannel.
Important: callbacks in the task queue do not execute immediately when ready. They wait until the call stack is empty. This is the event loop's job — it continuously checks: 'Is the stack empty? If yes, take the next task from the queue and push it onto the stack.'
This explains why setTimeout(fn, 0) does not execute immediately. The 0ms means: 'Put fn in the task queue as soon as possible.' But fn still waits for the current call stack to clear AND for all microtasks to run first.
The Microtask Queue: Priority Lane
Microtasks have higher priority than macrotasks. After each macrotask completes (or when the stack becomes empty), the event loop drains the entire microtask queue before taking the next macrotask.
Microtask sources include: Promise.then/catch/finally callbacks, async/await continuations (after each await), queueMicrotask(), and MutationObserver callbacks.
This is why Promises always resolve before setTimeout callbacks. Even if both are 'ready' at the same time, microtasks run first.
Danger: if a microtask queues another microtask (and that one queues another, recursively), the microtask queue never empties, and macrotasks (including rendering) are starved. This creates an infinite loop that freezes the page.
The Classic Assessment Question
Question: What is the output order of the following code?
console.log('1'); setTimeout(() => console.log('2'), 0); Promise.resolve().then(() => console.log('3')); console.log('4');
Step-by-step execution: (1) console.log('1') executes immediately → prints '1'. (2) setTimeout registers callback in Web API → returns immediately. (3) Promise.resolve().then() places callback in microtask queue → returns immediately. (4) console.log('4') executes immediately → prints '4'. (5) Call stack is now empty. Event loop checks microtask queue → runs Promise callback → prints '3'. (6) Microtask queue is empty. Event loop takes next macrotask → runs setTimeout callback → prints '2'.
Output: 1, 4, 3, 2. The synchronous code runs first (1, 4), then microtasks (3), then macrotasks (2). This execution order is deterministic and guaranteed by the specification.
async/await and the Event Loop
async/await is syntactic sugar over Promises. When a function hits an await expression, it pauses that function's execution and returns control to the caller. The continuation (code after the await) is scheduled as a microtask.
Consider: async function foo() { console.log('A'); await Promise.resolve(); console.log('B'); } console.log('C'); foo(); console.log('D');
Output: C, A, D, B. Explanation: 'C' prints first (synchronous). foo() is called: 'A' prints (synchronous part of foo). await is hit: foo pauses, 'B' is scheduled as a microtask. Control returns to caller: 'D' prints. Stack is empty: microtask runs, 'B' prints.
The key insight: everything before the first await in an async function runs synchronously. Only the code after await is deferred. This is why placing heavy computation before await still blocks the thread.
Practical Implications for Real Code
Understanding the event loop helps you avoid common bugs and performance issues:
Never block the event loop with long synchronous operations. If you have heavy computation, break it into chunks using setTimeout(chunk, 0) or use Web Workers for true parallelism.
Know that UI updates happen between macrotasks. If you update the DOM and immediately read its dimensions, you may get stale values. Use requestAnimationFrame for visual updates that need to sync with the render cycle.
Avoid creating infinite microtask loops. A recursive Promise chain without a setTimeout escape valve will freeze the browser. Always ensure microtask chains terminate.
In Node.js, the event loop has additional phases (timers, pending callbacks, poll, check, close). process.nextTick() runs before all other microtasks, which can starve I/O if overused.
Frequently Asked Questions
Is the event loop part of JavaScript itself or the environment?
The event loop is part of the runtime environment (browser or Node.js), not the JavaScript language specification (ECMAScript). ECMAScript defines the job queue semantics for Promises, but the overall event loop, task queues, and Web API integration are defined by the HTML specification (for browsers) or libuv (for Node.js).
What is the difference between queueMicrotask() and setTimeout(fn, 0)?
queueMicrotask(fn) schedules fn as a microtask — it runs before any macrotask and before rendering. setTimeout(fn, 0) schedules fn as a macrotask — it runs after microtasks and potentially after a render frame. Use queueMicrotask when you need something to run as soon as possible but after the current synchronous code finishes.
Can Promises run in parallel in JavaScript?
Promise callbacks always run on the single main thread, never in parallel. However, the operations Promises represent (network requests, file I/O) can happen concurrently in the environment's native layer. Promise.all() initiates multiple async operations and waits for all to complete — the operations overlap in time, but their callbacks execute sequentially.
Ready to practice?
Put this into action with our independently reviewed practice material.
Start practising free