1. Introduction
JavaScript is a single-threaded language, meaning it runs on a single call stack, executing one line of code at a time. However, it still manages to handle asynchronous operations like API calls, timeouts, user interactions, and file I/O efficiently. This ability is powered by the Event Loop, an integral part of the JavaScript runtime environment.
Understanding the event loop is crucial for:
- Writing performant asynchronous code
- Avoiding common bugs related to execution order
- Mastering tools like Promises,
async/await
, and callbacks
2. JavaScript Runtime Environment Overview
The event loop works as part of a broader system — the JavaScript runtime. Let’s look at its key components.
a. Call Stack
- The call stack is a LIFO (Last-In, First-Out) data structure.
- It keeps track of function invocations.
- When a function is called, it is pushed onto the top of the stack.
- When the function returns a value or finishes executing, it is popped off the stack.
- Only one function can run at a time on the stack.
- If a function calls another function, the new function gets stacked on top of the previous one.
b. Web APIs (or Node APIs)
- These are not part of the JavaScript language itself but are provided by the browser (or Node.js).
- Web APIs handle asynchronous operations like
setTimeout
,DOM events
,fetch
, geolocation, etc. - These operations are delegated to background threads managed by the browser or Node.js runtime.
- Once the operation completes, a callback function is registered for execution in the callback queue or microtask queue.
c. Callback Queue (Task Queue / Message Queue)
- This queue holds functions (callbacks) coming from completed asynchronous operations such as
setTimeout
,setInterval
,fetch
, or DOM events. - These callbacks wait in line until the call stack is completely empty.
- The event loop checks this queue and moves the first available task to the call stack for execution.
- Tasks in this queue are executed in FIFO (First-In, First-Out) order.
d. Microtask Queue
- This queue is similar to the callback queue but has higher priority.
- It stores microtasks, which are usually callbacks from:
Promise.then()
,catch()
, andfinally()
- MutationObserver
queueMicrotask()
(a manual way to schedule microtasks)
- After each synchronous task, all microtasks are executed before the event loop moves on to the next task from the callback queue.
- Microtasks can themselves queue more microtasks, which can potentially delay the event loop from progressing.
e. Event Loope.
- The event loop is a perpetual process that coordinates between the call stack, microtask queue, and callback queue.
- It continuously performs the following checks:
- Is the call stack empty?
- Are there any microtasks to run? If yes, run all of them.
- Are there tasks in the callback queue? If yes, move the first one to the call stack and run it.
- Repeat.
- It ensures asynchronous code execution in a non-blocking fashion, keeping the application responsive.
3. Event Loop Execution Cycle: Step-by-Step
Let’s understand the actual working cycle of the event loop with more precision:
- Start executing the script (synchronously):
- The main code runs line by line and functions are invoked.
- Each invoked function is pushed to the call stack.
- Encounter an asynchronous function:
- Functions like
setTimeout
,fetch
, or event listeners are delegated to the Web APIs. - JavaScript does not wait for them to finish; instead, it continues executing subsequent lines.
- Functions like
- Async task completes:
- When an async operation (e.g., timeout or network response) finishes, the associated callback is queued:
- Microtasks (like
Promise
callbacks) go to the microtask queue. - Other callbacks (e.g.,
setTimeout
) go to the callback queue.
- Microtasks (like
- When an async operation (e.g., timeout or network response) finishes, the associated callback is queued:
- Event loop checks the call stack:
- If the call stack is empty, it proceeds to the next step.
- Execute microtasks:
- The event loop moves all pending microtasks to the call stack one by one and executes them.
- Execute callback tasks:
- After the microtask queue is cleared, one task from the callback queue is moved to the call stack and executed.
- Repeat the cycle:
- This process repeats indefinitely, enabling smooth execution of asynchronous operations.
4. Visual Representation
+-------------------+ +------------------------+ | Call Stack | <------ | Event Loop | +-------------------+ +------------------------+ ^ | | | v v +-------------------+ +------------------------+ | Microtask Queue | | Callback Queue | +-------------------+ +------------------------+ ^ ^ | | +-------------------+ +------------------------+ | Web APIs | ----> | Async Task Completion | +-------------------+ +------------------------+
5. Detailed Example Scenarios
Scenario 1: Basic setTimeout
with 0ms Delay
console.log('Start'); setTimeout(() => { console.log('Timeout callback'); }, 0); console.log('End');
Output
Start End Timeout callback
Explanation:
setTimeout
is delegated to the Web API.- After 0 milliseconds, the callback is queued in the callback queue.
- However, it still waits until the call stack is empty.
- Hence,
'End'
is printed before the timeout callback.
Scenario 2: Promises vs setTimeout
console.log('Start'); Promise.resolve().then(() => { console.log('Promise resolved'); }); setTimeout(() => { console.log('Timeout callback'); }, 0); console.log('End');
Output:
Start End Promise resolved Timeout callback
Explanation:
- The promise callback is a microtask, so it has higher priority.
- It runs immediately after the synchronous code, but before the
setTimeout
callback.
Scenario 3: Chained Promises and Task Queues
console.log('Script start'); setTimeout(() => { console.log('setTimeout'); }, 0); Promise.resolve().then(() => { console.log('Promise 1'); }).then(() => { console.log('Promise 2'); }); console.log('Script end');
Output:
Script start Script end Promise 1 Promise 2 setTimeout
Explanation:
- Both promises (
then
) go into the microtask queue. setTimeout
goes into the callback queue.- All microtasks (
Promise 1
,Promise 2
) are executed before moving tosetTimeout
.
6. Key Differences Between Microtasks and Callback Queue
Feature | Microtask Queue | Callback Queue |
Priority | Higher | Lower |
When it runs | Immediately after the current task | After the microtask queue clears |
Examples | Promise , queueMicrotask , MutationObserver | setTimeout , setInterval , fetch |
Can block next frame? | Yes (if recursive) | No |
7. Potential Pitfalls
a. Starving the Event Loop
Long-running microtask chains can prevent the event loop from progressing:
function blockLoop() { Promise.resolve().then(blockLoop); } blockLoop(); // Starves the event loop, browser becomes unresponsive
b. Misunderstanding setTimeout(fn, 0)
- Many believe
setTimeout(fn, 0)
will run “immediately” — it won’t. - It still waits for:
- Current call stack to clear
- All microtasks to complete
c. Nested Promise
and setTimeout
setTimeout(() => { console.log('Timeout 1'); Promise.resolve().then(() => { console.log('Microtask inside Timeout'); }); }, 0);
Output:
Timeout 1 Microtask inside Timeout
Explanation: The microtask inside the timeout runs before the next task after the timeout.
8. Summary: How Things Are Ordered
The full execution order looks like this:
- Execute global script (synchronous code).
- After the stack is empty:
- Process all microtasks (
Promise.then
,queueMicrotask
, etc.)
- Process all microtasks (
- Then:
- Process the next task from the callback queue (like
setTimeout
)
- Process the next task from the callback queue (like
- Repeat this forever.