Javascript Promises - The Definitive Guide
The single-threaded, event-loop based concurrency model of JavaScript, deals with processing of events using so-called “asynchronous non-blocking I/O model. ” Unlike computer languages such as Java, where events are handled using additional threads, processed in parallel with the main execution thread, JavaScript code is executed sequentially. In order to prevent blocking the main thread on I/O-bound operations, JavaScript uses a callback mechanism where asynchronous operations specify a callback - the function to be executed when the result of an asynchronous operation is ready; while the code control flow continues executing.
Whenever we want to use the result of a callback to make another asynchronous call, we need to nest callbacks. Since I/O operations can result in errors, we need to handle errors for each callback before processing the success result. This necessity to do error handling and having to embed callbacks makes the callback code difficult to read. Sometimes this is referred to as “ JavaScript callback hell ”.
In order to address this problem, JavaScript offers a mechanism called a Promise. It is a common programming paradigm (more about it here: https://en.wikipedia.org/wiki/Futures_and_promises ) and TC39 introduced it in ECMAScript 2015. The JavaScript Promise is an object holding a state, which represents an eventual completion (or failure) of an asynchronous operation and its resulting value.
A new Promise is in the pending state. If a Promise succeeds it is put in a resolved state otherwise it is rejected . Instead of using the original callback mechanism, code using Promises creates a Promise object. We use Promises typically with two callback handlers - resolved invoked when the operation was successful and rejected called whenever an error has occurred.
Process.nextTick(callback)
To understand how Promises work in Node.js, it is important to review how process.nextTick() works in Node.js, as the two are very similar. Process.nextTick() is a method that adds a callback to the “next tick queue”. Tasks in the queue are executed after the current operation in the event loop is done and before the event loop is allowed to continue. Simply said, there’s another queue beside the event loop that we can use to schedule events. This queue is even faster than the event loop and it may be drained several times in a single event loop tick.
In the example above we can see how process.nextTick works in practice. We have 2 setTimeout calls, with callbacks immediately scheduled in the event loop. We also have 2 process.nextTick methods with callbacks scheduled in the “next tick queue”. This is what we see in the console:
Since we know that “next tick queue” is separate from event loop and can be drained multiple times in a single event loop tick, this makes sense. Two nextTick callbacks are executed immediately and the other two setTimeout callbacks, set in the event loop, are executed after.
Putting so many callbacks in the “next tick queue” may block the event loop and prevent any I/O operation. That’s why we have process.maxTickDepth that represents the maximum number of callbacks in the queue that can be executed before allowing the event loop to continue. Its default value is 1000.
How Do Promises work?
Promises are a new and nice way to handle async code, but how do they really work? For understanding the benefits and the performance characteristics of Promises we need to understand how they are implemented and what really happens when we return new Promise() .
Promises use the Microtask queue and they are executed independently from regular tasks (setTimeout callback for example). What does this really mean? In JavaScript, we have three queues: (1) event loop, (2) nextTick queue and (3) Microtask queue. All those queues work independently. Macrotasks are regular tasks that are going into the event loop and in one event loop tick, only one Macrotask is executed. Microtasks have an independent queue and in one event-loop tick the whole microtasks queue can be drained. This gives us a really good performance benefit. Basically, we use microtasks when we need to do stuff asynchronously in a synchronous way, as fast as possible. Promises are executed as Microtasks . This means that they are executed sooner than Macrotasks . They are never executed concurrently. Microtasks are always executed sequentially, so talking about parallelism with Promises is wrong. They work like process.nextTick , independently from event loop in their own microtask queue. Macrotasks: setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI rendering Microtasks: process.nextTick, Promises, Object.observe, MutationObserver (read more here )
In the example above, the code in the Promise will be scheduled in the Microtask queue, but since that action requires the network, it will only be resolved after the data is received. In this example, we’ll see this output:
We also need to mention that the timing of callbacks and Promises can vary significantly depending on the environment (browser or Node.js).
Promise methods
Promise.all(iterable)
It takes an array of Promises and returns a Promise that either fulfils when all of the Promises in the iterable argument have fulfilled or rejects as soon as one of the Promises rejects. If the returned Promise fulfils, it's fulfilled with an array of the values from the fulfilled Promises in the same order as defined in the array argument. If the returned Promise rejects, it is rejected with the reason from the first Promise in the array that got rejected. This method can be useful for aggregating results of multiple Promises.
The biggest confusion about Promise.all is that Promises passed in the iterable are executed concurrently. Promise.all doesn’t provide parallelism! The function passed in the Promise constructor is executed immediately and Promise is resolved in the microtask queue. Microtasks are always executed in sequence.
This method is useful when we want to wait for multiple Promises to resolve (or reject) without manually chaining them. The most common use case is mapping through an array and returning a Promise for every element:
The first rejection of a Promise will cause Promise.all() to reject, but other constituent Promises will still be executing. This can be harmful as we will be using resources for generating results that won’t be used.
In the example above, we’re passing 2 Promises in the Promise.all(). The first one is waiting one second and then logging letter b in the console. The second one is rejected with the letter a . Since the second one is rejected, we would expect to see only a in the console, but you’ll see a and b . That’s because you can’t cancel the Promise. Every scheduled Promise will be executed and Promise.all just helps us to ignore the result if one of the Promises in the iterable is rejected, and gives us a rejected Promise as a result.
Promise.race(iterable)
It takes an array of Promises and executes them in the same way as Promise.all, the difference being it returns a Promise that fulfils or rejects as soon as one of the Promises in the iterable fulfils or rejects, with the value or reason from that Promise. As an example, Promise.race can be used for building a timeout functionality, where the first Promise will be an HTTP request to some service, and a second one will be a timeout function. If the second one fails first, the resulting Promise from Promise.race() will be rejected and the data from the first Promise won’t be available. The rejection of one Promise from the iterable won’t cancel others, they will be still be executed, as in the Promise.all method case.
Promise.reject( reason ) Returns a Promise object that is rejected with the given reason as an argument. It is mainly used to throw an error in the Promise chain.
Promise.resolve(value)
Returns a Promise that is resolved with the given value as an argument. It is mainly used to cast a value into the Promise, some object or array, so we can chain it later with other async code.
Async/Await
In ECMAScript 2017 async / await semantics were added, allowing programmers to deal with Promises in a more intuitive way. The word “async” before a function means one simple thing: a function always returns a Promise. If the code has return in it, then JavaScript automatically wraps it into a resolved Promise with that value. The keyword await , which can only occur inside an async function, makes JavaScript wait until the Promise has been settled and returns its result.
Below is a function waiting on a Promise that is resolved after one second using async / await keywords:
Common mistakes with Promises
People always say that whatever you write in JS it will be executed and it will work. That’s almost true, but not always the correct way to do things. At the end of the day, when you’re making quality products, it should be fast and bug-free. By using Promises, you can make some really bad mistakes really easily, just by forgetting to put some part of the code or by using them incorrectly. Below is a list of the common mistakes with Promises that a lot of people make every day.
Mistake #1: Nested Promises
Check the code below:
Promises were invented to fix the “callback hell” and the above example is written in the “callback hell” style. To rewrite the code correctly, we need to understand why the original code was written the way that it was. In the above situation, the programmer needed to do something after results of both Promises are available, hence the nesting. We need to rewrite it using Promise.all() as:
Check the error handling too. When you use Promises properly, only one catch() is needed.
A Promise chain also gives us a finally() handler. It’s always executed and it’s good for cleanup and some final tasks that will always be executed, no matter if the Promise was resolved or rejected. It was added in the ES2018 version of ECMAScript and implemented in Node.js 10. It is used like this:
Mistake #2: Broken Promise Chain
One of the main reasons Promises are convenient to use is “promise-chaining” - an ability to pass the result of a Promise down the chain and call catch at the end of the chain to catch an error in one place. Let’s look at the below example:
The problem with the code above is handling somethingElse() method. An error that occurred inside that segment will be lost. That’s because we didn't return a Promise from the somethingElse() method. By default, .then() always returns a Promise, so in our case, somethingElse() will be executed, the Promise returned won’t be used and .then() will return a new Promise. We just lost the result from the somethingElse() method. We could easily rewrite this as:
Mistake #3: Mixing sync and async code in a Promise chain
This is one of the most common mistakes with Promises. People tend to use Promises for everything and then to chain them, even for async code. It’s probably easier to have error handling on one place, to easily chain your code, but using Promise chains for that is not the right way to do. You’ll just be filling your memory and giving more job to the garbage collector. Check the example below:
In this example, we have mixed sync and async code in the Promise chain. Two of those methods are getting the data from Github, the other two are just mapping through arrays and extracting some data and the last one is just logging the data in the console (all 3 are sync). This is the usual mistake people make, especially when you have to fetch some data, then to fetch some more per every element in the fetched array, but that’s wrong. By transforming getDataAndFormatIt() method into async/await, you can easily see where the mistake is:
As you see, we’re treating every method as async (that’s what will happen in the example with Promise chain). But we don’t need Promise for every method, only for 2 async methods, other 3 are sync. By rewriting the code a bit more, we’ll finally fix the memory issue:
That’s it! It’s now written properly, only methods that are async will be executed as Promises, other ones will be executed as sync methods. We don’t have a memory leak anymore. You should avoid chaining async and sync methods, that will make a lot of problems in the future (especially when you have a lot of data to process). If it’s easier for you, go for async/await, it will help you understand what should be the Promise and what should stay sync method.
Mistake #4: Missing catch
JavaScript does not enforce error handling. Whenever programmers forget to catch an error, JavaScript code will raise a runtime exception. The callback syntax, however, makes error handling more intuitive. Every callback function receives two arguments, error and result . By writing the code you’ll always see that unused error variable and you’ll need to handle it at some point. Check the code below:
Since the callback function signature has an error , handling it becomes more intuitive and a missing error handler is easier to spot. Promises make it easy to forget to catch errors since .catch() is optional while .then() is perfectly happy with a single success handler. This will emit the UnhandledPromiseRejectionWarning in Node.js and might cause a memory or file descriptor leak. The code in the Promise callback takes some memory and cleaning of the used memory after Promise is resolved or rejected should be done by the garbage collector. But that might not happen if we don’t handle rejected promise properly. If we access some I/O source or create variables in the Promise callback, a file descriptor will be created and memory will be used. By not handling the Promise rejection properly, memory won’t be cleaned and file descriptor won’t be closed. Do this several hundred times and you’ll make big memory leak and some other functionality might fail. To avoid process crashes and memory leaks always finish Promise chains with a . catch() .
If you try to run the code below, it will fail with the UnhandledPromiseRejectionWarning :
We’re trying to read stats for a file that doesn’t exist and we’re getting the following error:
(node:34753) UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory, stat 'non-existing-file.txt'
(node:34753) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
(node:34753) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
If you don’t handle your errors properly, you’ll leak a file descriptor or get into some other denial of service situation. That’s why you need to clean up everything properly on Promise rejection. To properly handle it, we should add a .catch() statement here:
With a .catch() statement, when the error happens we should log it in the console:
We recommend checking out this blog post from Matteo Collina that will help you understand this issue and learn about the operational impact of unhandledRejection.
Mistake #5: Forget to return a Promise
If you are making a Promise, do not forget to return it. In the below code, we forget to return the Promise in our getUserData() success handler.
As a result, userData is undefined. Further, such code could cause an unhandledRejection error. The proper code should look like:
Mistake #6: Promisified synchronous code
Promises are designed to help you manage asynchronous code. Therefore, there are no advantages to using Promises for synchronous processing. As per JavaScript documentation: “The Promise object is used for deferred and asynchronous computations. A Promise represents an operation that hasn't yet completed, but is expected to in the future.” What happens if we wrap synchronous operation in a Promise, as below?
The function passed to the Promise will be invoked immediately but the resolution will be scheduled on the microtask queue, like any other asynchronous task. It will just be blocking the event loop without a special reason. In the above example, we created an additional context, which we are not using. This will make our code slower and consume additional resources, without any benefits. Furthermore, since our function is a Promise, the JavaScript engine will skip one of the most important code optimizations meant to reduce our function call overhead - automatic function inlining .
Mistake #7: Mixing Promise and Async/Await
This code is long and complicated, it uses Promises, Async/Await, and callbacks. We have an async function inside a Promise, a place where it is not expected and not a good thing to do. This is not expected and can lead to a number of hidden bugs. It will allocate additional Promise objects that will be unnecessarily wasting memory and your garbage collector will spend more time cleaning it.
If you want to use async behaviour inside a Promise, you should resolve them in the outer method or use Async/Await to chain the methods inside. We can refactor the code like this:
Mistake #8: Async function that returns Promise
This is unnecessary, a function that returns a Promise doesn’t need an async keyword, and the opposite, when the function is async, you don’t need to write `return new Promise` inside. Use the async keyword only if you’re going to use await in the function, and that function will return a Promise when invoked.
or
Mistake #9: Define a callback as an async function
People often use async functions in places where you shouldn’t expect them, like a callback. In the example below, we’re using async function as a callback on the even from server:
This is an anti-pattern. It is not possible to await for the result in the EventEmitter callback, the result will be lost. Also, any error that’s thrown (can’t connect to the DB for example) won’t be handled, you’ll get unhandledRejection , there’s no way to handle it.
Working with non-Promise Node.js APIs
Promises are a new implementation in ECMAScript and not all Node.js APIs are made to work in that way. That’s why we have a Promisify method that can help us generate a function that returns a Promise from a function that works with a callback:
It was added in Node.js version 8. and you can find more about it here.
Conclusion
Every new feature in a programming language makes people excited but eager to try it. But no one wants to spend time trying to understand the functionality; we just want to use it. That’s where the problem arises. The same goes with Promises; we were waiting for them for a long time and now they are here we’re constantly making mistakes and failing to understand the whole point around using them.
In my opinion, they’re a cool feature but also quite complicated. In this post we wanted to show you what’s happening under the hood when you write return new Promise() and what are some common mistakes that everyone makes. Write your code carefully or one day it may come back to you with some unhandled promise rejections or memory leaks.
If you get into bigger problems with Promises and need some assistance with profiling, we recommend using Node Clinic - a set of tools that can help you diagnose performance issues in your Node.js app.
Safe coding!
Insight, imagination and expertly engineered solutions to accelerate and sustain progress.
Contact