loading...
Cover image for Best Practices for ES6 Promises

Best Practices for ES6 Promises

somedood profile image Basti Ortiz (Some Dood) Updated on ・8 min read

ES6 promises are great! They are integral constructs for asynchronous programming in JavaScript, ultimately replacing the old callback-based pattern that was most infamously known for bringing about deeply nested code ("callback hell").

Unfortunately, promises are not exactly the easiest concept to grasp. In this article, I will discuss the best practices I have learned over the years that helped me make the most out of asynchronous JavaScript.

Handle promise rejections

Nothing is more frustrating that an unhandled promise rejection. This occurs when a promise throws an error but no Promise#catch handler exists to gracefully handle it.

When debugging a heavily concurrent application, the offending promise is incredibly difficult to find due to the cryptic (and rather intimidating) error message that follows. However, once it is found and deemed reproducible, the state of the application is often just as difficult to determine due to all the concurrency in the application itself. Overall, it is not a fun experience.

The solution, then, is simple: always attach a Promise#catch handler for promises that may reject, no matter how unlikely.

Besides, in future versions of Node.js, unhandled promise rejections will crash the Node process. There is no better time than now to make graceful error handling a habit.

Keep it "linear"

In a recent article, I explained why it is important to avoid nesting promises. In short, nested promises stray back into the territory of "callback hell". The goal of promises is to provide idiomatic standardized semantics for asynchronous programming. By nesting promises, we are vaguely returning to the verbose and rather cumbersome error-first callbacks popularized by Node.js APIs.

To keep asynchronous activity "linear", we can make use of either asynchronous functions or properly chained promises.

import { promises as fs } from 'fs';

// Nested Promises
fs.readFile('file.txt')
  .then(text1 => fs.readFile(text1)
    .then(text2 => fs.readFile(text2)
      .then(console.log)));

// Linear Chain of Promises
const readOptions = { encoding: 'utf8' };
const readNextFile = fname => fs.readFile(fname, readOptions);
fs.readFile('file.txt', readOptions)
  .then(readNextFile)
  .then(readNextFile)
  .then(console.log);

// Asynchronous Functions
async function readChainOfFiles() {
  const file1 = await readNextFile('file.txt');
  const file2 = await readNextFile(file1);
  console.log(file2);
}

util.promisify is your best friend

As we transition from error-first callbacks to ES6 promises, we tend to develop the habit of "promisifying" everything.

For most cases, wrapping old callback-based APIs with the Promise constructor will suffice. A typical example is "promisifying" globalThis.setTimeout as a sleep function.

const sleep = ms => new Promise(
  resolve => setTimeout(resolve, ms)
);
await sleep(1000);

However, other external libraries may not necessarily "play nice" with promises out of the box. Certain unforeseen side effects—such as memory leaks—may occur if we are not careful. In Node.js environments, the util.promisify utility function exists to tackle this issue.

As its name suggests, util.promisify corrects and simplifies the wrapping of callback-based APIs. It assumes that the given function accepts an error-first callback as its final argument, as most Node.js APIs do. If there exists special implementation details1, library authors can also provide a "custom promisifier".

import { promisify } from 'util';
const sleep = promisify(setTimeout);
await sleep(1000);

Avoid the sequential trap

In the previous article in this series, I extensively discussed the power of scheduling multiple independent promises. Promise chains can only get us so far when it comes to efficiency due to its sequential nature. Therefore, the key to minimizing a program's "idle time" is concurrency.

import { promisify } from 'util';
const sleep = promisify(setTimeout);

// Sequential Code (~3.0s)
sleep(1000)
  .then(() => sleep(1000));
  .then(() => sleep(1000));

// Concurrent Code (~1.0s)
Promise.all([ sleep(1000), sleep(1000), sleep(1000) ]);

Beware: promises can also block the event loop

Perhaps the most popular misconception about promises is the belief that promises allow the execution of "multi-threaded" JavaScript. Although the event loop gives the illusion of "parallelism", it is only that: an illusion. Under the hood, JavaScript is still single-threaded.

The event loop only enables the runtime to concurrently schedule, orchestrate, and handle events throughout the program. Loosely speaking, these "events" indeed occur in parallel, but they are still handled sequentially when the time comes.

In the following example, the promise does not spawn a new thread with the given executor function. In fact, the executor function is always executed immediately upon the construction of the promise, thus blocking the event loop. Once the executor function returns, top-level execution resumes. Consumption of the resolved value (through the Promise#then handler) is deferred until the current call stack finishes executing the remaining top-level code.2

console.log('Before the Executor');

// Blocking the event loop...
const p1 = new Promise(resolve => {
  // Very expensive CPU operation here...
  for (let i = 0; i < 1e9; ++i)
    continue;
  console.log('During the Executor');
  resolve('Resolved');
});

console.log('After the Executor');
p1.then(console.log);
console.log('End of Top-level Code');

// Result:
// 'Before the Executor'
// 'During the Executor'
// 'After the Executor'
// 'End of Top-level Code'
// 'Resolved'

Since promises do not automatically spawn new threads, CPU-intensive work in subsequent Promise#then handlers also blocks the event loop.

Promise.resolve()
//.then(...)
//.then(...)
  .then(() => {
    for (let i = 0; i < 1e9; ++i)
      continue;
  });

Take memory usage into consideration

Due to some unfortunately necessary heap allocations, promises tend to exhibit relatively hefty memory footprints and computational costs.

In addition to storing information about the Promise instance itself (such as its properties and methods), the JavaScript runtime also dynamically allocates more memory to keep track of the asynchronous activity associated with each promise.

Furthermore, given the Promise API's extensive use of closures and callback functions (both of which require heap allocations of their own), a single promise surprisingly entails a considerable amount of memory. An array of promises can prove to be quite consequential in hot code paths.

As a general rule of thumb, each new instance of a Promise requires its own hefty heap allocation for storing properties, methods, closures, and asynchronous state. The less promises we use, the better off we'll be in the long run.

Synchronously settled promises are redundant and unnecessary

As discussed earlier, promises do not magically spawn new threads. Therefore, a completely synchronous executor function (for the Promise constructor) only has the effect of introducing an unnecessary layer of indirection.3

const promise1 = new Promise(resolve => {
  // Do some synchronous stuff here...
  resolve('Presto');
});

Similarly, attaching Promise#then handlers to synchronously resolved promises only has the effect of slightly deferring the execution of code.4 For this use case, one would be better off using global.setImmediate instead.

promise1.then(name => {
  // This handler has been deferred. If this
  // is intentional, one would be better off
  // using `setImmediate`.
});

Case in point, if the executor function contains no asynchronous I/O operations, it only serves as an unnecessary layer of indirection that bears the aforementioned memory and computational overhead.

For this reason, I personally discourage myself from using Promise.resolve and Promise.reject in my projects. The main purpose of these static methods is to optimally wrap a value in a promise. Given that the resulting promise is immediately settled, one can argue that there is no need for a promise in the first place (unless for the sake of API compatibility).

// Chain of Immediately Settled Promises
const resolveSync = Promise.resolve.bind(Promise);
Promise.resolve('Presto')
  .then(resolveSync)  // Each invocation of `resolveSync` (which is an alias
  .then(resolveSync)  // for `Promise.resolve`) constructs a new promise
  .then(resolveSync); // in addition to that returned by `Promise#then`.

Long promise chains should raise some eyebrows

There are times when multiple asynchronous operations need to be executed in series. In such cases, promise chains are the ideal abstraction for the job.

However, it must be noted that since the Promise API is meant to be chainable, each invocation of Promise#then constructs and returns a whole new Promise instance (with some of the previous state carried over). Considering the additional promises constructed by intermediate handlers, long chains have the potential to take a significant toll on both memory and CPU usage.

const p1 = Promise.resolve('Presto');
const p2 = p1.then(x => x);

// The two `Promise` instances are different.
p1 === p2; // false

Whenever possible, promise chains must be kept short. An effective strategy to enforce this rule is to disallow fully synchronous Promise#then handlers except for the final handler in the chain.

In other words, all intermediate handlers must strictly be asynchronous—that is to say, they return promises. Only the final handler reserves the right to run fully synchronous code.

import { promises as fs } from 'fs';

// This is **not** an optimal chain of promises
// based on the criteria above.
const readOptions = { encoding: 'utf8' };
fs.readFile('file.txt', readOptions)
  .then(text => {
    // Intermediate handlers must return promises.
    const filename = `${text}.docx`;
    return fs.readFile(filename, readOptions);
  })
  .then(contents => {
    // This handler is fully synchronous. It does not
    // schedule any asynchronous operations. It simply
    // processes the result of the preceding promise
    // only to be wrapped (as a new promise) and later
    // unwrapped (by the succeeding handler).
    const parsedInteger = parseInt(contents);
    return parsedInteger;
  })
  .then(parsed => {
    // Do some synchronous tasks with the parsed contents...
  });

As demonstrated by the example above, fully synchronous intermediate handlers bring about the redundant wrapping and unwrapping of promises. This is why it is important to enforce an optimal chaining strategy. To eliminate redundancy, we can simply integrate the work of the offending intermediate handler into the succeeding handler.

import { promises as fs } from 'fs';

const readOptions = { encoding: 'utf8' };
fs.readFile('file.txt', readOptions)
  .then(text => {
    // Intermediate handlers must return promises.
    const filename = `${text}.docx`;
    return fs.readFile(filename, readOptions);
  })
  .then(contents => {
    // This no longer requires the intermediate handler.
    const parsed = parseInt(contents);
    // Do some synchronous tasks with the parsed contents...
  });

Keep it simple!

If you don't need them, don't use them. It's as simple as that. If it's possible to implement an abstraction without promises, then we should always prefer that route.

Promises are not "free". They do not facilitate "parallelism" in JavaScript by themselves. They are simply a standardized abstraction for scheduling and handling asynchronous operations. If the code we write isn't inherently asynchronous, then there is no need for promises.

Unfortunately, more often than not, we do need promises for powerful applications. This is why we have to be cognizant of all the best practices, trade-offs, pitfalls, and misconceptions. At this point, it is only a matter of minimizing usage—not because promises are "evil", but because they are so easy to misuse.

But this is not where the story ends. In the next part of this series, I will extend the discussion of best practices to ES2017 asynchronous functions (async/await).


  1. This may include specific argument formats, initialization operations, clean-up operations, and so on and so forth. 

  2. In essence, this is what it means to schedule a "microtask" in the "microtask queue". Once the current top-level code finishes executing, the "microtask queue" waits for all scheduled promises to be settled. Over time, for each resolved promise, the "microtask queue" invokes the respective Promise#then handler with the resolved value (as stored by the resolve callback). 

  3. With the added overhead of a single promise. 

  4. With the added overhead of constructing a new promise for each chained handler. 

Posted on 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
 

Some great Promise tips here! I've certainly raised my eyebrows at long Promise chains, also kudos for util.promisify. On one of your tips,

The solution, then, is simple: always attach a Promise#catch handler for promises that may reject, no matter how unlikely.

I've been told this as well, but in practice it's just a bit cumbersome to uphold this principle. I mean, wouldn't every Promise have the possibility to reject? You may end up with .catch handlers everywhere, and you would have to define behavior for every catch handler, even those unlikely to reject. It makes testing a whole world of pain. What works best for me (and continues to work for me) is to ensure Promises rejections are being handled correctly everywhere so that all rejections are bubbled up (no more uncaught Promise rejection). All rejections would then exit your code; if this behavior is ever undesirable, those are the points you should write error handling code.

 

That is definitely a more practical approach. Personally, I've recently found Rust's error handling idioms more elegant than that of JavaScript promises. They have this notion of a Result type that I really like. I'll be writing about it in the near future, where I'll advocate for its usage with JavaScript promises.

Regarding the part you quoted, I would like to add that I was careful to say "may reject" since there are some promises that cannot possibly reject. For example, it is unreasonable to attach a catch handler for Promise.resolve. Something along those lines.

 

Found rust docs on error handling. The thing is, I like exceptions, and I like that all errors thrown in JavaScript behave pretty much like panic!. Rust provides language semantics to differentiate between recoverable errors (Result) and unrecoverable errors (panic!). However, I don't really see how this is better than throwing errors. Perhaps try/catch blocks are clunky? I should mention that I'm neck deep in functional JavaScript and I don't even use try catch blocks, just tryCatch. <- catches synchronous errors and rejected Promises, also disclaimer that I wrote that function

On "may reject", I think I was triggered by the "always", but touche!

Yup, I was coming from the mindset that try/catch blocks are kinda "clunky", which is why I stylistically preferred Rust's error handling.

I'm neck deep in functional JavaScript...

Wise decision. The functional style is indeed beautiful. I never liked try/catch blocks anyway. 😂

 

while agree with "Synchronously settled promises are redundant and unnecessary"
dev.to/somedood/best-practices-for...

I'd just like to mention a case where I do tend to intentionally (over)use promises even in cases where they'd be flagged as redundant and unnecessary.

The use case is around API consistency, for example, if I've got a library or a class that mostly uses asynchronous function, ans then I have a function or two that are synchronous, I tend to wrap the synchronous functions in a promise, so the usage is consistent. I've found this especially useful when working with frameworks like Vue, where I can keep the usage in the component consistent. I've also gotten to the habit of choosing code readability/maintainability over performance, YMMV.

 

Yeah, I'd count this as an exception. I did mention in the article that it can be okay "for the sake of API compatibility". 👍

 

Nice it the explanation is much easier that I thought. Honestly promises felt like voodoo to me cause I had search for tons of video on it being explained clearly.

 

"Voodoo" is such an accurate description. 😂

 

Yeah it's really bad as most of the articles I came across didn't had a good explanation on it till I had stumbled across this video by Engineer Man.