const fs = require('fs');
// Callback-based Asynchronous Code
fs.readFile('file.txt', (err, text) => {
if (err) throw err;
console.log(text)
});
// ES6 Promises
fs.promises.readFile('file.txt')
.then(console.log)
.catch(console.error);
After many years of using the callback pattern as the de facto design pattern for asynchronous code in JavaScript, ES6 Promises finally came in 2015 with the goal of streamlining asynchronous operations. It consequently eliminated the dreaded callback hell, the seemingly infinite regress of nested callback functions. Thanks to ES6 Promises, asynchronous JavaScript suddenly became arguably cleaner and more readable... or did it? π€
Multiple Asynchronous Operations
When concurrently executing multiple asynchronous operations, one can utilize Promise.all
in order to effectively accomplish this goal without causing too many issues with the event loop.
In the Promise
-based example below, an array of Promises
will be passed into the Promise.all
method. Under the hood, the JavaScript engine cleverly runs the three concurrent readFile
operations. Once they have all been resolved, the callback for the following Promise#then
in the chain can finally execute. Otherwise, if at least one of the operations fail, then the Error
object from that operation will be passed into the nearest Promise#catch
.
const fs = require('fs');
const FILES = [ 'file1.txt', 'file2.txt', 'file3.txt' ];
// Callback-based
function callback(err, text) {
if (err) throw err;
console.log(text);
}
for (const file of FILES)
fs.readFile(file, callback);
// `Promise`-based
const filePromises = FILES.map(file => fs.promises.readFile(file));
Promise.all(filePromises)
.then(texts => console.log(...texts))
.catch(console.error);
The issues with promises only begin to appear when multiple asynchronous operations need to be executed one after the other in a specific order. This is where callback hell reintroduces itself to both callback-based and promise-based asynchronous chains.
const fs = require('fs');
const fsp = fs.promises;
// The Traditional Callback Hell
fs.readFile('file1.txt', (err, text1) => {
if (err) throw err;
console.log(text1);
fs.readFile('file2.txt', (err, text2) => {
if (err) throw err;
console.log(text2);
fs.readFile('file3.txt', (err, text3) => {
if (err) throw err;
console.log(text3);
// ...
});
});
});
// The Modern "Promise" Hell
fsp.readFile('file1.txt')
.then(text1 => {
console.log(text1);
fsp.readFile('file2.txt')
.then(text2 => {
console.log(text2);
fsp.readFile('file3.txt')
.then(text3 => {
console.log(text3));
// ...
})
.catch(console.error);
})
.catch(console.error);
})
.catch(console.error);
The Better Way
One can solve the issue of nested promises by remembering that the return value of the callback function will always be wrapped in a resolved Promise
that will later be forwarded to the next Promise#then
in the chain (if it isn't a Promise
itself already). This allows the next Promise#then
to use the return value from the previous callback function and so on and so forth...
In other words, return values are always wrapped in a resolved Promise
and forwarded to the next Promise#then
in the chain. The latter can then retrieve the forwarded return value through the corresponding callback function. The same is true for thrown values (ideally Error
objects) in that they are forwarded as rejected Promise
s to the next Promise#catch
in the chain.
// Wrap the value `42` in
// a resolved promise
Promise.resolve(42)
// Retrieve the wrapped return value
.then(prev => {
console.log(prev);
// Forward the string 'Ping!'
// to the next `Promise#then`
// in the chain
return 'Ping!';
})
// Retrieve the string 'Ping!' from
// the previously resolved promise
.then(prev => {
console.log(`Inside \`Promise#then\`: ${prev}`);
// Throw a random error
throw new Error('Pong!');
})
// Catch the random error
.catch(console.error);
// Output:
// 42
// 'Inside `Promise#then`: Ping!'
// Error: Pong!
With this knowledge, the "Promise Hell" example above can now be refactored into a more "linear" flow without the unnecessary indentation and nesting.
const fsp = require('fs').promises;
fsp.readFile('file1.txt')
.then(text1 => {
console.log(text1);
return fsp.readFile('file2.txt');
})
.then(text2 => {
console.log(text2);
return fsp.readFile('file3.txt');
})
.then(text3 => {
console.log(text3);
// ...
})
.catch(console.error);
In fact, this "linear" promise flow is the exact pattern promoted by the basic examples for the Fetch API. Consider the following example on a basic interaction with the GitHub REST API v3:
// Main endpoint for the GitHub REST API
const API_ENDPOINT = 'https://api.github.com/';
fetch(API_ENDPOINT, { method: 'GET' })
// `Response#json` returns a `Promise`
// containing the eventual result of the
// parsed JSON from the server response.
// Once the JSON has been parsed,
// the promise chain will forward the
// result to the next `Promise#then`.
// If the JSON has been malformed in any
// way, then an `Error` object will be
// constructed and forwarded to the next
// `Promise#catch` in the chain.
.then(res => res.json())
.then(console.log)
.catch(console.error);
The async
/await
Way
With the much beloved async
/await
feature of ES2017 asynchronous functions, it is now possible to work around the issue of order-sensitive asynchronous operations. It hides the verbosity of cumbersome callback functions, the endless Promise#then
chains, and the unnecessary nesting of program logic behind intuitive layers of abstraction. Technically speaking, it gives an asynchronous operation the illusion of a synchronous flow, thereby making it arguably simpler to fathom.
const fsp = require('fs').promises;
async function readFiles() {
try {
console.log(await fsp.readFile('file1.txt'));
console.log(await fsp.readFile('file2.txt'));
console.log(await fsp.readFile('file3.txt'));
} catch (err) {
console.error(err);
}
}
Nevertheless, this feature is still prone to improper usage. Although asynchronous functions necessitate a major rethinking of promises, old habits die hard. The old way of thinking about promises (through nested callbacks) can easily and perniciously mix with the new flow and concepts of ES2017 asynchronous functions. Consider the following example of what I would call the "Frankenstein Hell" because of its confusing mixture of callback patterns, "linear" promise flows, and asynchronous functions:
const fs = require('fs');
// Needless to say... this is **very** bad news!
// It doesn't even need to be many indentations
// deep to be a code smell.
fs.readFile('file1.txt', async (err, text1) => {
console.log(text1);
const text2 = await (fs.promises.readFile('file2.txt')
.then(console.log)
.catch(console.error));
});
To make matters worse, the example above can even cause memory leaks as well. That discussion is beyond the scope of this article, but James Snell explained these issues in detail in his talk "Broken Promises" from Node+JS Interactive 2019.
Conclusion
ES6 Promises and ES2017 Asynchronous Functionsβalthough quite readable and extensively powerful in and of itselfβstill require some effort to preserve its elegance. Careful planning and designing of asynchronous flows are paramount when it comes to avoiding the issues associated with callback hell and its nasty reincarnations.
In particular, nested promises are a code smell that may indicate some improper use of promises throughout the codebase. Since the return value of the callback will always be forwarded to the callback of the next Promise#then
in the chain, it's always possible to improve them by refactoring in such a way that takes advantage of callback return values and asynchronous functions (if feasible).
Please don't nest promises. Even promises can introduce the dreaded callback hell.
Top comments (6)
Useful topic and nice post, great job!
Indeed promise nesting is widespread among novice developers.
In my opinion it happens coz Promise.then() still considered as a hook to attach callback on certain 'event' (as we did with callbacks) and the fact that Promise.then() RETURNS something is often missed.
Btw, just had a crazy idea. I think it might be better if a callback in Promise.then() must explicitly returns only promise (if you want to return value - Promise.resolve(100)) to avoid complexity with implicit wrapping.
Best Practices for ES6 Promises
Basti Ortiz (Some Dood) γ» Jul 22 γ» 8 min read
Well, it can help alleviate the confusion, but I recommend reading my recent article on best practices for ES6 promises. In it, I explained why it's best to refrain from wrapping values in promises as much as possible. I particularly discouraged the use of
Promise.resolve
, so I think that might be worth your read. πIs there any kind of nesting in programming that isn't a code smell?
Is nesting in markup a code smell?
Hmm... that's a good question. To that I will argue no, there is no such thing as a "good kind of nesting" in any programming language, even for markup.
I would like to expound on the specific case of HTML because deeply nested markup can be an indication of a poorly planned layout structure teeming with so-called "CSS hacks". On the discussion of performance, deeply nested markup also takes it toll on the amount of memory the DOM consumes. All those wrapper
div
s may seem innocent at first, but having a bunch of them will definitely be taxing for lower-end devices, especially on mobile.For data-oriented nesting such as in XML and JSON, perhaps I can be more lenient, but even then, deeply nested markup requires a bunch of computer resources just to traverse, not even at the data processing stage yet.
I decided to branch this into another thread
When is nesting good or neutral?
Ben Halpern γ» Jan 6 γ» 1 min read
Shameless self plug:
I have written a post about promises and how to use them:
Click
It has a lot of examples and explains promises from the basics!