← Back to all posts

Promises Worth Keeping

08 March 2019

Written by 


Ben Paddock
Tech Lead


This post delves into some of the pitfalls encountered when working with JavaScript promises, suggests some improvements and looks at a brief glimpse of the future of asynchronous programming in JavaScript.

This was first presented at one of our internal tech team talks and encouraged our CTO to rethink the coding of our latest JavaScript project here at Talis.


Some Background

Asynchronous workflows can be difficult to read and maintain. Callbacks suffer from these asynchronous pitfalls due to each step in the workflow adding another level of indentation which exacerbates the problem. Nesting is best left to birds! It’s also possible to forget to call a callback, meaning that a process is left hanging. Additionally, what happens when you want to pass an error back instead of data?

Promises are an improvement on this predicament. They return a handle onto an asynchronous event eventually completing or failing and they also enforce the separation of success and error handling code. However, you can still fall into the same pitfalls of nesting and forgetting to fulfill or reject a promise.

The introduction of async/await to the ECMAScript 2017 edition of JavaScript takes a big stride in improving asynchronous workflow management by allowing code to be written to make the workflow appear synchronous. There is also the added bonus of them being interoperable with promises.

Whilst these improvements are welcome, we’re not all in the position of being on the latest and greatest version of JavaScript. Many of us also have to maintain codebases written some time in the past. This blog post provides some insight on our journey from callbacks to async/await, by first getting our promises in order.

Broken Promises

It’s still possible to get into similar trouble with promises as we can with callbacks. Consider the following example of a request -> response workflow in a node controller class:

process(request) {
    const tenantCode = request.path.match(/\w*/g)[1];

    return new Promise((resolve, reject) => {
        this.deserialiseRequest(request).then((model) => {
            this.validateRequest(tenantCode, model).then(() => {
                this.persistData(model).then((persisted) => {
                    this.serialiseResponse(tenantCode, persisted).then((response) => {
                        resolve(response);
                    });
                }).catch((persistError) => {
                    this.serialiseResponse(persistError);
                });
            }).catch((validationError) => {
                reject(validationError);
            });
        }).catch((deserialisationError) => {
            this.serialiseResponse(deserialisationError);
        });
    });
}

There are a few things going on here. A request is being deserialised into a model, then validated, then persisted, then the result of that persistance is being serialised into a response. Each class method is returning a promise that can either be resolved or rejected (triggering then and catch respectively).

Along the way, there are at least three error scenarios. One thing that is immediately striking is that we still have nesting, just like callbacks! It’s also hard to spot what error can be thrown where.

For example, if the validationError catch block was removed, what do you think would happen when a validation error did occur? The catch block below will not come to the rescue here. The validateRequest promise rejection will not be handled, and this is bad news.

Fatal Rejections

Handling errors in promises is a big deal. We noticed this deprecation warning appear in Node 4:

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()

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.

It’s clear from this that knowing how and where your promise workflow handles errors is key to avoiding unexpected problems at runtime.

Thankfully, there is a safety net you can use by listening to unhandledRejection and rejectionHandled process events

Rose-linted Glasses

Using a linting tool such as ESlint combined with a promises plugin can help indicate to a developer that they’re not writing promises in the most appropriate way. The example above reveals the following, often repeated, violations:

[eslint] Each then() should return a value or throw [promise/always-return]
[eslint] Avoid nesting promises. [promise/no-nesting]
[eslint] Avoid nesting promises. [promise/no-nesting]
[eslint] Each then() should return a value or throw [promise/always-return]
[eslint] Avoid nesting promises. [promise/no-nesting]

The Flat-chaining pattern

In order to remove the nesting and allow the workflow logic to be seen more easily, we have started to introduce flat promise-chaining. The idea being that each promise takes an input, does something with it and then passes it into the next promise.

Now, the request -> response workflow looks like this:

process(request) {
  return this
    .deserialiseRequest(request)
    .then(this.validateRequest)
    .then(this.persistData.bind(this))
    .then(this.serialiseResponse);
}

Each promise can be seen as a step in the workflow. It should only do one small thing to keep the step simple, there could be many steps involved after all. When you resolve a promise it only takes a single argument and so you can use an object to capture the data as it is passed along the chain. The ES6 spread operator and destructuring syntax makes this easier to achieve:

validateRequest(input) {
    return new Promise((resolve, reject) => {
        const { model, tenantCode } = input;
        // ... do something with input
        return resolve({ ...input, additionalData: true });
    });
}

If any promise in the chain encounters a problem, it can throw an error and a single catch function can handle any of these at the end of the chain:

process(request).catch(serialiseError);

The Future

The future looks better thanks to async/await. An example request -> response workflow can now look like this, bringing greater legibility to the code:

const modelWithTenantCode = await this.deserialiseRequest(request);
await this.validateRequest(modelWithTenantCode);
const persisted = await this.persistData(modelWithTenantCode);
return await this.serialiseResponse(persisted);

You can surround this block with a single try/catch as well, to handle any errors.

If you are using a version of node that supports this then what are you waiting for! Our CTO converted a project full of callbacks to async/wait and finds reading and reasoning with the code far easier as a result.

If you’re running an older version of Node then you’ll be encouraged to know that async/await is interoperable with promises. This means the promises you write now will work in async/await workflows without any effort, making the upgrade path far more manageable.

The main takeaway from this journey has been that asynchronous programming can be hard to read and reason with. We think through workflows in linear terms but they don’t always turn out this way in JavaScript. Thankfully the standard is improving and lessons are being learned.

All of the examples in the blog post are available in full on GitHub