Asynchronous JavaScript & Event Loop from Scratch
JavaScript is fundamentally a synchronous, single-threaded language. It possesses a single call stack where code is executed line-by-line in a strict, blocking sequence. To perform asynchronous operations—such as delaying execution with timers, fetching remote data across a network, or listening to user interactions on a webpage—JavaScript relies on the runtime environment provided by the browser.
This chapter breaks down the mechanics of the JavaScript runtime, detailing how the Call Stack, Web APIs, the Callback Queue (Task Queue), the Microtask Queue, and the Event Loop collaborate to make asynchronous non-blocking JavaScript possible.
1. The Call Stack & Synchronous Execution Limits
At its core, JavaScript code runs inside the JavaScript Engine. The engine houses the Call Stack, which operates on a Last-In, First-Out (LIFO) basis.
Execution Lifecycle of Pure Synchronous Code
Whenever a JavaScript program begins execution:
- A Global Execution Context (GEC) is created and pushed onto the Call Stack.
- The code executes line-by-line sequentially.
- When a function invocation is encountered, a new Function Execution Context (FEC) is created and pushed on top of the Call Stack.
- Once the function finishes executing, its execution context is popped off the Call Stack.
- When the entire program finishes running, the GEC is popped off, leaving the Call Stack completely empty.
function a() {
console.log("a");
}
a();
console.log("End");
The Inherent Limitation of Pure JavaScript
The JavaScript engine has no internal concept of time delays, no built-in timer hardware, no ability to make HTTP requests across the internet, and no native access to the screen's DOM tree. The Call Stack has only one directive: execute whatever piece of code is on top immediately without waiting.
To execute code asynchronously after a delay or upon a network event, JavaScript requires additional capabilities provided by an external environment: the Browser.
2. Browser Architecture & Web APIs ("Superpowers")
The web browser is not merely a host for the JavaScript engine; it is a feature-rich operating environment that provides access to system-level operations.
Exposing Browser Superpowers via Web APIs
To make these browser capabilities accessible to the JavaScript code running inside the Call Stack, the browser exposes them as Web APIs packaged inside a single global object: window.
// Accessing browser APIs via the global window object:
window.setTimeout();
window.document.getElementById();
window.fetch();
window.localStorage;
window.console.log();
Note: Because
windowis the global object in the browser runtime environment, developers can access these APIs with or without thewindow.prefix (e.g.,setTimeout(...)is shorthand forwindow.setTimeout(...)).
3. Asynchronous Workflow with setTimeout
When an asynchronous Web API such as setTimeout is called, the execution does not pause or block the Call Stack.
console.log("Start");
setTimeout(function cbT() {
console.log("Callback");
}, 5000);
console.log("End");
Step-by-Step Execution Mechanics
-
Global Context Initialization:
GECis pushed onto the Call Stack.
-
Executing
console.log("Start"):- Calls the
consoleWeb API to log"Start"in the console immediately.
- Calls the
-
Executing
setTimeout(cbT, 5000):- Invokes the
setTimeoutWeb API. - The browser registers the callback function
cbTin the Web APIs environment and starts a hardware timer for5000 ms. setTimeoutcompletes its invocation immediately; it does not block the Call Stack.
- Invokes the
-
Executing
console.log("End"):- Logs
"End"in the console immediately. - The synchronous script finishes, and
GECis popped off the Call Stack. The Call Stack is now empty.
- Logs
-
Timer Expiration:
- After
5000 ms, the browser's timer expires. - The callback function
cbTis ready to execute, but it cannot jump directly into the Call Stack. - Instead,
cbTis placed inside the Callback Queue (also known as the Task Queue).
- After
4. DOM Event Listeners & The Web API Registry
Event listeners follow the same fundamental architecture as timers, with one key difference: lifespan and trigger conditions.
console.log("Start");
document.getElementById("btn").addEventListener("click", function cbR() {
console.log("Callback");
});
console.log("End");
Registration and Event Lifecycle
Persistent Registration
Unlike setTimeout (which triggers once and deregisters itself after timer completion), event listeners stay active inside the Web APIs environment continuously until explicitly removed via removeEventListener or until the web page/tab is closed.
5. The Need for the Callback Queue
A naive question often arises: Why does the browser need a Callback Queue? Why can't the Web API push the callback directly onto the Call Stack when the event happens?
The Burst Event Scenario
Consider a scenario where a user repeatedly clicks a button 5 or 6 times in rapid succession:
If callbacks bypassed a queue:
- Multiple callbacks would attempt to mutate the Call Stack simultaneously, causing memory race conditions and corrupting the single-threaded execution model.
The Queue Solution
The Callback Queue (Task Queue) acts as a buffer adhering to standard First-In, First-Out (FIFO) semantics:
Every user action or expired timer pushes a callback reference onto the end of the queue. The callbacks wait patiently in line until the Call Stack becomes available to process them one by one.
6. The Role of the Event Loop
The Event Loop acts as a continuous gatekeeper and orchestrator between the Call Stack and the queues.
The Event Loop Protocol
The Event Loop has a simple, focused task:
- Continuously monitor the Call Stack.
- Continuously monitor the Callback Queue and the Microtask Queue.
- If and only if the Call Stack is completely empty:
- Check if there are callbacks waiting in the queues.
- Take the first callback from the queue and push it onto the Call Stack for execution.
7. Network Requests & The Microtask Queue
Not all asynchronous callbacks are routed through the standard Callback Queue. Modern JavaScript distinguishes between regular tasks and higher-priority tasks via the Microtask Queue.
The fetch() API Workflow
console.log("Start");
setTimeout(function cbT() {
console.log("CB Timeout");
}, 5000);
fetch("https://api.netflix.com")
.then(function cbF() {
console.log("CB Netflix");
});
console.log("End");
Execution Details:
console.log("Start")logs"Start".setTimeoutregisterscbTwith a5000 mstimer in the Web API container.fetch()initiates a network call via the browser's networking layer and registerscbFvia the promise resolution handler (.then).console.log("End")logs"End".- Suppose the remote server responds to the
fetch()request in 50 ms—long before the 5000 ms timeout finishes:- When the network request completes,
cbFis ready to execute. - Crucially:
cbFis not pushed to the Callback Queue. - Because it originates from a Promise,
cbFis placed into the Microtask Queue.
- When the network request completes,
8. Microtask Queue vs. Callback Queue (Task Queue)
The JavaScript runtime strictly prioritizes the Microtask Queue over the Callback Queue (also referred to in specifications as the Task Queue or Macrotask Queue).
What Goes Where?
Comparative Breakdown
9. Complete End-to-End Execution Trace
To solidify the complete lifecycle, let us trace a comprehensive code snippet combining synchronous statements, long-running loops, a setTimeout, and a fast fetch call.
console.log("Start");
setTimeout(function cbT() {
console.log("CB Timeout");
}, 5000);
fetch("https://api.netflix.com")
.then(function cbF() {
console.log("CB Netflix");
});
// Simulating heavy synchronous work / thousands of lines of code
console.log("End");
Execution Timeline Trace
Final Console Output:
Start
End
CB Netflix
CB Timeout
10. Starvation of the Callback Queue
Because the Event Loop gives unconditional priority to the Microtask Queue, an edge case known as Task Starvation can occur.
The Starvation Mechanism
If a microtask continuously schedules another microtask before completing, the Microtask Queue will never become empty. As a result, the Event Loop will never grant execution time to callbacks waiting in the Callback Queue.
// Conceptual demonstration of Callback Queue Starvation:
function recursiveMicrotask() {
Promise.resolve().then(() => {
// Continuously creates microtasks without yielding
recursiveMicrotask();
});
}
// This timeout callback will starve and never run:
setTimeout(() => {
console.log("I will never execute!");
}, 1000);
recursiveMicrotask();
Warning: Starvation blocks user event handlers (UI clicks) and timer executions, causing the web application to appear frozen and unresponsive.
11. Architectural Summary & Component Map
The diagram below illustrates the unified relationship between all components of the JavaScript runtime and the browser host environment:
Summary and Key Takeaways
- Single-Threaded Execution: JavaScript possesses a single Call Stack and executes code synchronously line-by-line inside execution contexts.
- Browser Superpowers: Capabilities like timers (
setTimeout), network calls (fetch), storage (localStorage), and DOM manipulation are not native JavaScript language features; they are Web APIs provided by the browser environment. - The
windowObject: The browser exposes all Web APIs to the JavaScript engine through the globalwindowobject. - Asynchronous Registration: When an async Web API is called, the browser registers the callback in the Web APIs environment and handles the timer or network task in the background without blocking the Call Stack.
- Callback Queue (Task Queue): When Web API tasks (such as timer expirations or user clicks) finish, their associated callbacks wait in the Callback Queue.
- Microtask Queue Priority: Callbacks from Promises (
fetch,.then) and Mutation Observers go to the Microtask Queue, which has strictly higher priority than the Callback Queue. - The Event Loop's Core Function: The Event Loop continuously checks if the Call Stack is empty. When empty, it first drains all tasks from the Microtask Queue before processing tasks from the Callback Queue.
- Callback Starvation: If microtasks continuously generate new microtasks, tasks in the Callback Queue (including timers and UI click handlers) will never execute, leading to starvation.