Coding With Promises in Node.js



This page explains how Promises are typically used in Node.js. The examples here are meant to give an introduction to Promises and to highlight some of the challenges that the Profound.js framework solves. These examples are not intended to show how Promises are used in Profound.js. That is covered in Coding With Promises in Profound.js.

A Simple Example

Here is a simple example of an asynchronous operation done with Promises:

const fs = require("fs/promises"); let promise = fs.readFile("data.json", "utf8"); promise.then( function(data) { console.log(data); }, function (error) { console.error(error); } ); console.log("This output will appear before the file data");

fs/promises is part of the standard Node.js fs API.  It provides alternate versions of the API methods that use Promises instead of callbacks. Each asynchronous fs/promises API returns a Promise object. Every Promise object has a then() method. The functions passed to the then() method are called when the Promise “settles”.

A Promise can settle in one of two ways. Either the asynchronous operation completes successfully, in which case we say the Promise has been “fulfilled”, or it fails due to an error. In that case we say that the Promise has been “rejected”. The first function passed to then() is executed if/when the Promise is fulfilled. The 2nd function is executed if/when the Promise rejects.

Promise Chains

Note that the output from the 2nd console.log() call will appear before the file data. This behavior is what asynchronous I/O is all about, and what can make it challenging to code. All asynchronous calls return immediately, without waiting on the I/O. This allows other code to execute while the system works on the I/O. So, any code that needs to execute after the I/O completes must run from the then() callbacks. This can make it tricky to control the sequence of events, compared to top-down programming.

Consider the case where 2 asynchronous operations need to run in the correct sequence. For example, let’s say that we need to read two files and the first file’s data contains information that we need to determine what file to read next. Then when both files are read, we need to do something with the data from both. The code might look something like this:

const fs = require("fs/promises"); let data = []; fs.readFile("data1.txt", "utf8") .then(function(data1) { data.push(data1); return fs.readFile("data2.txt", "utf8"); }) .then(function(data2) { data.push(data2); doSomething(data); }) .catch(function(error) { console.error(error); });

This code uses a Promise chain. A Promise’s then() method returns a newly generated Promise that can be used for chaining. The new Promise returned from then() resolves to the value returned from the callback function passed to it. Callback functions passed to then() always return a Promise, even if not explicitly coded to do so. If the function returns some other type of value, that value is implicitly wrapped in a Promise that resolves to the given value.

In this case, the first then() callback returns a Promise to read the second file, so the second file data is passed into the next then() callback. A Promise also includes a standard catch() method that this code uses to handle errors. The catch() method returns a Promise that deals with rejected cases only. It is called when a Promise in the chain without a 2nd then() callback is rejected.

Promise chains may seem simple at first glance, but in practice things can get complicated. For example, if an error reading the first file needs to be handled differently than for the second, then each then() call in the chain would need a second callback. This can quickly lead straight back to Callback Hell.

Top-down Coding with async/await

There is an alternative to Promise chains. JavaScript’s async and await keywords can be used to create a much simpler version of the above example:

const fs = require("fs/promises"); async function processFiles() { try { let data1 = await fs.readFile("data1.txt", "utf8"); let data2 = await fs.readFile("data2.txt", "utf8"); doSomething([data1, data2]); } catch (error) { console.error(error); } }



The await keyword causes execution to seemingly “pause” until the Promise settles. The actual behavior is more complicated than that, but “pausing” is a good way to think about it for now. Execution “resumes” when the Promise settles. If the Promise is fulfilled, then the value is returned. If the Promise is rejected, then await throws the error as an exception.

The await keyword can only be used within a function that is defined with the async keyword. The async keyword has the effect of making a function return a Promise, even if it’s not explicitly coded to do so. If the function returns a different type of value, that value is implicitly converted to a fulfilled Promise for the given value.

So, async and await are just wrappers for Promises and Promise chains that allow for a more intuitive top-down coding style. However, these keywords come with pitfalls of their own:

  • In the above example, if an imaginary caller of the processFiles() function needs to wait for processing to complete, then it needs to use the await Which then means the caller needs to be defined with async. Then the same thing applies to the caller’s caller, and so on, all the way up the call stack.

  • It’s easy to forget to code the await keyword where needed and the result of that can be very confusing:

    • A single missing await will throw off top-down execution and cause code to run in unexpected ways.

    • A missing await will prevent conversion of Promise rejections to thrown exceptions, which causes try/catch to not handle exceptions in the expected way.

  • The await keyword is not allowed outside of a function in a traditional Node.js (CommonJS) module.

In a complex real-world application, these pitfalls often lead to long confusing debug sessions, only to find a single call buried somewhere in otherwise perfect code is missing an await.