Asynchronous JavaScript: Callbacks, Promises, and the Event Loop
JavaScript is a single-threaded language, meaning it can only execute one piece of code at a time. But then how and why is it used in modern web applications that fetch data, handle multiple requests at same time, all without freezing the server.
That is possible because of asynchronous capabilities. Here we will go through the evolution of asynchronous JavaScript, exploring Callbacks, diving deep into Promises and finally understanding the main thing, The Event Loop.
1. Callbacks
A callback is simply a function passed into another function as an argument, to be executed later once an async task completes. For a long time, callbacks were the primary way to handle async tasks in JavaScript.
setTimeout(() => {
console.log("Data fetched!");
}, 1000);
We use Callbacks for the following reasons
Simplicity: At their core, callbacks are incredibly simple to understand. You pass a function, and it gets called later.
Low Overhead: Callbacks are just normal functions, making them lightweight and highly performance.
The Cons of Callbacks
Callback Hell (The Pyramid of Doom): When you have multiple async tasks that depend on each other, callbacks become deeply nested. The code grows horizontally instead of vertically, making it unreadable and hard to maintain.
fetchUser((user) => {
fetchProfile(user.id, (profile) => {
fetchPosts(profile.id, (posts) => {
console.log(posts);
});
});
});
Inversion of Control: This is the most critical con. When you pass a callback to a third-party library, you hand over control of your program's execution to that library. You have to trust that they will call your function exactly once, not multiple times, not too early, and not too late.
Unclear Error Handling: In a deeply nested callback structure, you have to handle errors at every single level. You cannot easily use standard try/catch blocks.
2. Promises
To solve the issues with callbacks, Promises were introduced in ES6.
What Problem Are Promises Actually Solving?
Promises solve Inversion of Control and Callback Hell.
Instead of passing a callback into a function and hoping for the best, a Promise-based function returns an object to you immediately. This object acts as a placeholder for the eventual result of the async task. You regain control because you decide what to do with that returned object. Also, Promises allow chaining, which flattens nested callbacks into clean, vertical code, and they centralize error handling.
A Promise has three states:
Pending: The initial state when a task has just started execution
Fulfilled: The task completed successfully.
Rejected: The task failed.
Promise Methods
We interact with a promise using three instance methods -
.then(onFulfilled, onRejected): Schedules callbacks for when the promise is fulfilled (or rejected). Returns a new Promise, allowing chaining.
.catch(onRejected): Used for error handling for the entire chain in one block.
.finally(onFinally): Runs a callback regardless of whether the promise was fulfilled or rejected.
Promise Static Methods
1. Promise.resolve(value)
Returns a Promise that is already resolved with the given value. Useful for wrapping synchronous values in a Promise.
2. Promise.reject(reason)
Returns a Promise that is already rejected with the given reason (usually an Error object).
3. Promise.all(value)
Takes an array (or value) of Promises. It returns a single Promise that fulfills only when all input Promises fulfill.
- Problem it solves: Executing multiple independent async tasks in parallel.
4. Promise.allSettled(value)
Takes an array of Promises and fulfills only after all of them have settled (either fulfilled or rejected).
- Problem it solves: Waiting for multiple parallel tasks to finish, but ensuring that a single failure doesn't prevent you from seeing the results of the successful ones. It returns an array of objects describing the outcome of each Promise ({ status: "fulfilled", value } or { status: "rejected", reason }).
5. Promise.race(value)
Takes an array of Promises and returns a Promise that fulfills or rejects as soon as the first input Promise settles.
- Problem it solves: Implementing timeouts. You can "race" a network request against a setTimeout Promise that rejects after 5 seconds.
6. Promise.any(value)
Similar to .race(), but it waits for the first Promise to fulfill. It ignores rejections until all Promises have rejected. If all reject, it throws an error.
- Problem it solves: Querying multiple redundant servers/APIs and using the first one that successfully responds, ignoring the ones that fail.
7. Promise.withResolvers()
Returns an object containing a new Promise and its resolve and reject functions.
- Problem it solves: Previously, to extract the resolve/reject functions from a Promise scope to be used externally, you had to declare variables outside the Promise constructor.
3. The Event Loop
We know callbacks and Promises help us structure asynchronous code, but how does JavaScript physically execute them if it's strictly single-threaded? Enter The Event Loop.
To understand the Event Loop, we must understand the architecture of the JavaScript runtime -
The Call Stack: This is where your code actually runs. It's a LIFO data structure. When a function is called, it's pushed onto the stack. When it returns, it's popped off.
Web APIs: These are functionalities provided by the environment, not the JS engine. Things like setTimeout and fetch live here. They execute in the background using the browser's multi threaded capabilities.
The Task Queue (Macrotask Queue): When a Web API finishes its background work, it pushes its callback into this queue.
The Microtask Queue: This is a VIP queue with a higher priority than the Macrotask queue. This is where Promise .then(), .catch(), and .finally() callbacks go.
How the Event Loop Works
The Event Loop is a continuous algorithm that checks two things: Is the Call Stack empty? and Are there tasks in the Queues?
Here is its strict order of operations:
Execute all synchronous code in the Call Stack.
Once the Call Stack is empty, check the Microtask Queue (Promises).
Execute all tasks in the Microtask Queue one by one until the queue is completely empty. (If a microtask schedules another microtask, it gets executed right away in this phase).
Once the Microtask Queue is empty, check the Macrotask Queue (Timers, Clicks).
Take the first task from the Macrotask Queue, push it to the Call Stack, and execute it.
Repeats all process again.
Output Order:
Sync execution starts (Pushed to Call Stack, executed)
Sync execution ends (Pushed to Call Stack, executed. The setTimeout was sent to the Web APIs and its callback went to the Macrotask Queue. The Promise went to the Microtask Queue).
Microtask (Promise) (Call Stack is empty, so Event Loop checks the VIP Microtask Queue first).
Macrotask (setTimeout) (Microtask queue is empty, so Event Loop processes the Macrotask Queue).
Conclusion
Everything is controlled by Event Loop. It is what decided what process or task will be executed at what priority. Every callback is stacked in event loop for execution. All I/O operations also finally lead to the Event Loop for execution.