Learnitweb

JavaScript Event Loop

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(), and finally()
    • 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:
    1. Is the call stack empty?
    2. Are there any microtasks to run? If yes, run all of them.
    3. Are there tasks in the callback queue? If yes, move the first one to the call stack and run it.
    4. 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:

  1. 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.
  2. 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.
  3. 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.
  4. Event loop checks the call stack:
    • If the call stack is empty, it proceeds to the next step.
  5. Execute microtasks:
    • The event loop moves all pending microtasks to the call stack one by one and executes them.
  6. Execute callback tasks:
    • After the microtask queue is cleared, one task from the callback queue is moved to the call stack and executed.
  7. 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 to setTimeout.

6. Key Differences Between Microtasks and Callback Queue

FeatureMicrotask QueueCallback Queue
PriorityHigherLower
When it runsImmediately after the current taskAfter the microtask queue clears
ExamplesPromise, queueMicrotask, MutationObserversetTimeout, 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:

  1. Execute global script (synchronous code).
  2. After the stack is empty:
    • Process all microtasks (Promise.then, queueMicrotask, etc.)
  3. Then:
    • Process the next task from the callback queue (like setTimeout)
  4. Repeat this forever.