JavaScript is famously single-threaded — it executes one piece of code at a time in a synchronous manner. Yet, it handles asynchronous operations like timers, network requests, and DOM events gracefully without blocking. The secret behind this magic is the event loop, a core part of the JavaScript runtime that orchestrates concurrency.
In this article, we’ll explore how JavaScript’s event loop, call stack, and the two kinds of task queues — the microtask queue and macrotask queue — work together to enable asynchronous behavior in a single-threaded environment. By understanding this mechanism, you’ll be able to write more efficient and predictable async code.
The Call Stack: JavaScript’s Execution Engine
The call stack is at the heart of JavaScript’s execution model. Think of it like a stack of plates: when a function is called, it gets pushed onto the stack, and when it returns, it’s popped off.
- The call stack tracks what function is currently executing.
- Only one function runs at a time — JavaScript is single-threaded.
- If the stack is empty, the event loop checks the queues for more work to do.
Web APIs and the Macrotask Queue
JavaScript doesn’t run alone — it’s embedded in a host environment like a browser or Node.js, which provides asynchronous capabilities via Web APIs (like setTimeout
, fetch
, or DOM events).
- When you call an async API, it’s handled outside the call stack by the Web API environment.
- Once completed, the callback is queued in the macrotask queue (a.k.a. the callback queue).
- The event loop picks the next macrotask only after the call stack is empty and all microtasks are processed.
The Microtask Queue: Promises and More
The microtask queue (also known as the job queue) is another queue with higher priority than the macrotask queue.
- Microtasks include
.then()
callbacks of Promises,queueMicrotask
, andprocess.nextTick
(Node.js). - After each task (macrotask or initial script) completes and the call stack clears, the event loop drains the microtask queue — running all its callbacks before touching the next macrotask.
- This is why Promises often feel “faster” than
setTimeout
.
How the Event Loop Works
- Run the script’s synchronous code on the call stack.
- When the call stack is empty:
- Run all microtasks from the microtask queue.
- Then, take the next macrotask from the macrotask queue and push it onto the stack.
- Execute the macrotask.
- Repeat this cycle endlessly.
This system is what allows JavaScript to be asynchronous without the complexity of multithreading.
Event Loop in Action: A Simple Example
console.log("Start");
setTimeout(() => {
console.log("Macrotask: setTimeout");
}, 0);
Promise.resolve().then(() => {
console.log("Microtask: Promise");
});
console.log("End");
Step-by-step execution:
console.log("Start")
→ prints StartsetTimeout
schedules a callback in the macrotask queue- Promise
.then()
schedules a callback in the microtask queue console.log("End")
→ prints End- Call stack is now empty → event loop processes microtasks → prints Microtask: Promise
- Microtask queue is empty → event loop processes macrotasks → prints Macrotask: setTimeout
Output:
Start
End
Microtask: Promise
Macrotask: setTimeout
Summary: JavaScript’s Asynchronous Magic
- The call stack runs synchronous code.
- Web APIs handle async operations and enqueue callbacks in the macrotask queue.
- Promises and similar features enqueue callbacks in the microtask queue, which always runs first.
- The event loop coordinates all of this, ensuring a non-blocking experience despite JavaScript’s single-threaded nature.
Understanding how the event loop prioritizes tasks helps avoid surprises and lets you harness async JavaScript more effectively.