Promises and Async Flow in JavaScript
What Promises are in JavaScript, how to escape callback hell, and the essential patterns for chaining then/catch and managing async flow.
Async operations in JavaScript have always felt a little strange. The language is single-threaded, yet AJAX requests, timers, and file reads all happen without blocking. For years, callback functions were our primary tool for dealing with this. They work — but at some point they become completely unreadable.
Promise is an object introduced in ES6 that represents the future result of an async operation. It exists in one of three states: pending (not yet settled), fulfilled (completed successfully), or rejected (failed). This structure offers a far more readable and manageable alternative to nested callback chains.
Callback hell
To see where the problem comes from, consider this scenario: fetch a user, then fetch that user’s orders, then fetch the products in the first order.
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getProducts(orders[0].id, function(products) {
// Do something
}, function(err) {
console.error('Product error:', err);
});
}, function(err) {
console.error('Order error:', err);
});
}, function(err) {
console.error('User error:', err);
});
Each step requires two callbacks: one for success, one for failure. The deeper the indentation grows, the harder the code becomes to follow. This pattern is known as “callback hell” or the “pyramid of doom.”
The same flow with Promises
getUser(userId)
.then(function(user) {
return getOrders(user.id);
})
.then(function(orders) {
return getProducts(orders[0].id);
})
.then(function(products) {
// Do something with products
})
.catch(function(err) {
console.error('An error occurred:', err);
});
All errors are caught in a single catch block. If any step in the chain throws or rejects, execution jumps straight to catch. The code reads left-to-right, top-to-bottom.
Creating a Promise
To write your own function that returns a Promise, use the new Promise() constructor:
function delay(ms) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve('Wait complete');
}, ms);
});
}
delay(1000).then(function(message) {
console.log(message); // "Wait complete"
});
When resolve is called, the Promise fulfills and .then() fires. When reject is called — or an exception is thrown — .catch() fires.
Parallel requests with Promise.all
When you need to kick off multiple async operations simultaneously and wait for all of them, use Promise.all:
var userRequest = fetch('/api/user/1');
var settingsRequest = fetch('/api/settings');
Promise.all([userRequest, settingsRequest])
.then(function(responses) {
var userResponse = responses[0];
var settingsResponse = responses[1];
// Both are ready
})
.catch(function(err) {
// If either one fails, we end up here
});
Instead of waiting for them sequentially, both go out in parallel; the total time equals the duration of the slowest request.
Promise.all has one limitation: if any Promise in the array rejects, the entire group is considered failed and control goes to catch. If you want to let some operations fail while continuing with the rest, Promise.allSettled is the better fit — but that arrived in ES2020. Back in 2017 you had to handle that case by hand.
A common pitfall
Forgetting return in a Promise chain is one of the most frequent mistakes. If you don’t return a new Promise inside .then(), the rest of the chain continues without waiting for it:
// Wrong: missing return
.then(function(user) {
getOrders(user.id); // This Promise is never awaited
})
// Correct: return is present
.then(function(user) {
return getOrders(user.id);
})
This mistake is easy to make and hard to spot while debugging. The symptom: the orders parameter arrives as undefined in the next .then() because the previous step returned nothing. Anyone reading the code for the first time usually spends a few minutes hunting for this.
With ES6 arrow functions
Arrow functions make the chain noticeably more compact:
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getProducts(orders[0].id))
.then(products => console.log(products))
.catch(err => console.error(err));
A single-expression arrow function has an implicit return, which also reduces the chance of the missing-return bug. Just remember: as soon as you add a block body {}, the return is required again.
Truly internalizing Promises takes a bit of time — especially building the right mental model for how chaining behaves. Coming from PHP, where synchronous code runs line by line, the idea that “this function isn’t running right now, it will run later” requires a genuine paradigm shift. But once it clicks, you have no desire to go back to nested callback pyramids.
A Promise is not a value — it’s a commitment that a value will arrive. Once that idea sinks in, it becomes clear why each .then() in a chain returns a new Promise. The value doesn’t exist yet; you don’t know when it will be ready, but you’ve already described what to do when it is. That habit of defining future work ahead of time eventually opens the door to a much broader way of thinking about programming. Back in 2017, fully grasping this took me some time — but once I did, my whole relationship with JavaScript changed.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.