loading...
Cover image for Please don't nest promises

Please don't nest promises

somedood profile image Basti Ortiz (Some Dood) ・5 min read

Please don't... (4 Part Series)

1) Please don't commit .env 2) Please don't "overchain" array methods 3) Please don't forget to write the changelog 4) Please don't nest promises
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 Promises 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.

Please don't... (4 Part Series)

1) Please don't commit .env 2) Please don't "overchain" array methods 3) Please don't forget to write the changelog 4) Please don't nest promises

Posted on Jun 14 by:

somedood profile

Basti Ortiz (Some Dood)

@somedood

Just some dood trying to make code work without bringing the Universe to its demise.

Discussion

markdown guide
 

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 divs 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.

 
 

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!