How to code async JavaScript better

Avoiding the Promise Pyramid of Doom (promise hell)


As defined in Mozilla docs:

“The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.”

Simple as that. A Promise object has two chainable methods: then and catch.

So, to summarize:

  1. A is then executed (only once) if its promise is resolved.
  2. Any value (unless there is a promise rejection, of course) returned from within a then is treated as a promise resolution value, therefore can be handled in the next then of the chain or in a higher level.
  3. The next then is executed, etc…
  4. A “catch” is executed (only once) in case any promise in the chain is rejected.

The problem

So, that pipe-like way of chaining async processes is clean, very easy to understand and can be extended if done right. However, in real life, we chain more than two or three async functions and each one can contain more than two chained and nested functions. One function may depend on the result of one or more of the previous ones which in the end would create a nested mess. So we go back to score one with callback hell but with promises, or to use a buzz word (or term): the Promise Pyramid of Doom. And yes, it’s bad for the same reason, call back is hell.

How to solve it?

Actually is easier than it sounds, especially if you think about it up front right when you start coding your feature, but many developers don’t even try, or don’t see the point in doing so. So, like coding in general, it might seem easy for the one who wrote the code in the first place, but as the feature grows in complexity, understanding, refactoring or extending it, gets harder and harder to achieve.

Any suggestions? Yes!

1. Avoid using callbacks by all means. To this day, no one should be using callbacks to handle async processes, nevertheless, for some reason there are still many libraries doing it. So, avoid it in your code, and wrap third party callback based functions in promises.

2. Wrap async processes in separate functions. Although we can just pass an anonymous function to then and catch, when they’re too large or there are too many, the code gets messy and difficult to maintain, so creating new functions that receives everything they need as params, will help a lot.

3 . When executing async functions in a cascade (The result of one is the input of the next) or in a series (one after the other), instead of nesting them, try simple chaining.

4. Functions are first class citizens in JavaScript which means you can pass them as parameters to other functions. And since you can assign an anonymous function to a variable, you’re better off passing the variable as a param to your then functions every time possible.

5. Remember arrow functions implicit return feature.

Notice that using arrow functions make your code harder to debud, because arrow functions are anonymous, meaning that you will not see the function in the error trace. Read more here.

6. Everything returned from a then (except an explicit Promise rejection), is considered a promise resolution value, so you can just process your promise resolution in the next then:

So since getProductsRelatedToOrders receives orders as parameter which is resolved by getOrders, instead of invoking then again inside of the first one, you can just chain it.

This part:

` => ({…product, someImportantValue}))`

Creates a new array with the same elements in products array, but with a new attribute called someImportantValue (Of course, I would’ve tried to add that param inside of getProductsRelatedToOrders function, but I am using this as an example).

Notice we didn’t use Promise.resolve nor Promise.all. In a higher level, this new array would be received as a promise resolution value.

7. Never forget to catch.

7.1 Prefer catching errors on the top level.

7.2 Avoid more than one catch call in the same function.

7.3 If you’re wrapping an API, there’s a big chance you will want to change their errors format or add extra info; do so in your wrapping function and return a promise rejection.

8. Use async/await syntax. Although many developers argue we shouldn’t, since it’s the syntactic sugar on top of promises, it actually helps us avoid all of the problems related to cascade and series async functions execution. So yes, you should give it a try.

The function needs to be marked with “async”. This will allow you to use “await” inside the function. What “await“ does is it blocks execution until the promise is resolved or rejected. Notice the last function call depending on the results of the three previous functions; when working with promises, nesting would’ve been the only option. Read more.

9. If you choose to use async/await syntax:

9.1 Use try/catch.

9.2 Don’t ever mix promise and async/await syntax in the same function. Although you could call a promise then method or return Promise.resolve or Promise.reject from a function marked with async, avoid it, just for the sake of consistency. Remember everything returned from an async function will be considered a promise resolution value. Any error thrown will be considered a promise rejection reason.

9.3 As in promises, handle errors on the top level

9.4 Although you can always use it, it’s especially suitable for those cases when there’s a lot of promise nesting, when promises depend on more than one previous promise resolution value.

10. When dealing with multiple async processes in “parallel”, you can make use of the map and Promise.all.

But beware: Remember promise.all will end execution and call catch method on the first promise rejection, so be careful when you use it and how you decide to handle errors for all those functions. If you’re dealing with too many processes that take too long to be resolved, or third party API’s which you cannot totally rely on, maybe a queue solution will make a better job, since it can allow you to handle each process independently, handle errors better and be resilient with retry policies and dead letter queues.

11. Logs. Splitting your code into multiple async functions, some with nested promises, (perhaps) others with async/await syntax, can rapidly become difficult to debug, especially considering the heavy use of arrow functions these days. So use a logging library and make descriptive logs in each function so you can actually trace the data flow easier.

12. Remember the example in point 4.

Now considerfunctions can also be returned as the result of another function. So imagine the previous example, but this time our getOrders function needs an object that exists at the same level where findUsers was called. You might think, “well I can pass it to findUsers and then just return it”. The thing is that by doing this it could be misleading about what findUser does, besides if the only thing a function does with a param is return it, then that param should not be declared in the first place.

If you want to know more, check out this article about currying and partial application.

Find the cheese! 🧀