JavaScript Promises Unkept

With introduction of native Promise support and the async and await keywords promises have only gained popularity in JavaScript. But there are quirks they hide and the underlying asynchronous nature of JavaScript means you can sometimes end up with code that is harder to reason about. Here is a not so gentle introduction for those moving from callbacks to promises and looking forward to async/await.

Before we delve deeper, let us introduce some sample code first.

Here is a very simple function smallAdd that adds numbers, but it can only work with small numbers. If the second number is larger than 100, it errors. Take some time to study the code.

The function noop does nothing and smallAdd‘s callback parameter defaults to noop. smallAdd has been written following a popular code pattern that allows the caller to use promises or callbacks to invoke it. It supports both calling styles.

Before we go on lets introduce another method to the mix, print

print is doing nothing too exciting, it is written using the Node callback convention (err, result. It prints a heading --- Print --- followed by the result or the error, whichever is set.

Worst of both worlds

smallAdd tries to support both promises and callbacks. The problem of writing clever code that mixes promises and callbacks is that there are unintended side effects.

Let us try to invoke smallAdd using callbacks

--- Print ---
Got our results 21
--- Print ---
Got an error Error: Cannot have b > 100
    at code (/Users/ali/Documents/Projects/node.js Experiments/app.js:9:23)
    at new Promise (<anonymous>)
    at smallAdd (/Users/ali/Documents/Projects/node.js...

Okay so that was expected, the first call succeeds with result 21 and the second errors with a stack trace, but Node isn’t done shouting at us yet.

(node:73861) UnhandledPromiseRejectionWarning:Error: Cannot have b > 100
    at code (/Users/ali/Documents/Projects/node.js Experiments/app.js:9:23)
    at new Promise (<anonymous>)
    at smallAdd (/Users/ali/Documents/Projects/node.js...

(node:73861) 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:73861) [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.

Where is that coming from?

Well note how smallAdd returns a promise? Its the call to smallAdd(3, 108, print) which errors as expected, but also causes the returned promise to be rejected. When calling smallAdd with a callback we do not care about the rejected promise because the callback handles the error. However, Node does care about promises that are rejected and go unhandled and becomes very unhappy, spitting the error a second time with a dire warning.

The correct way to then call smallAdd when supplying a callback is

Alas, there is still a bug in the code above.

Gobbled exceptions

Here is our print function again, but this time with a typo

console is mistyped as onsole, a developer accidentally hit the delete key. This should cause a runtime error. But on running this code Node gives no error. In fact, no output is printed at all. Huh?

This time around what happens is more sinister. print is the callback function supplied to smallAdd. When smallAdd calls its callback = print, print now executes in the scope of the Promise constructor and because print errors, the Promise rejects.

Why?

Well, because that is what the promise constructors do, they automatically catch any errors during construction and treat them as a promise rejection. Only on this occasion it is not the code typed within the body of the promise constructor that errors, but the externally supplied callback function that does. This is subtle as we have no control on what an externally supplied callback will or will not do. No matter, the Promise constructor being still in scope it catches the error raised in the callback and rejects the promise. This rejected promise is then helpfully caught by the .catch(noop) we just added, which happily forwards the caught error to noop oblivion. Hence the output silence.

Congratulations, we now have bugs in our code that are very hard to trace!

Can it be fixed? Well yes, but the resultant code is not pretty to look at

With these changes to smallAdd, the callback is called after the Promise construction is complete and so any errors that happen in the callback do not impact the promise itself.

We now get the runtime error we expected, it hilights our typo, so we can go fix it

/Users/ali/Documents/Projects/node.js Experimen
ts/app.js:24
    onsole.log('--- Print ---');
    ^

ReferenceError: onsole is not defined
    at print (/Users/ali/Documents/Projects/nod
e.js Experiments/app.js:24:5)
    at process.nextTick (/Users/ali/Documents/P
rojects/node.js Experiments/app.js:17:32)
    at _combinedTickCallback (internal/process/
next_tick.js:131:7)
    at process._tickCallback (internal/process/
next_tick.js:180:9)
    at Function.Module.runMain (module.js:695:1
1)
    at startup (bootstrap_node.js:191:16)
    at bootstrap_node.js:612:3

Promises

So now let us go with just Promises. First we fix our print function and correct the typo.

okay, now we can use just promises to call our function

Which outputs the expected

-- Print ---
Got our results 21
--- Print ---
Got an error Error: Cannot have b > 100
    at code (/Users/ali/Documents/Projects/node.js Experiments/app.js:9:23)
    at new Promise (<anonymous>)
    at smallAdd (/Users/ali/Documents/Projects/node.js...

This is identical to the output we got when using callbacks, which is what we expected.

So you are convinced that going all promises will mean less worry, but hold on, not so fast. Do you really understand what is going on with these promise chains? They can get pretty long. So, can’t we just break ’em up?

A Secret Promise

N.B. This actually happened with one of my developers. He refactored the code like you see above to reduce the chain length of a pretty long promise chain.

Now when we run it, we get the expected error

--- Print ---
Got an error Error: Cannot have b > 100
    at code (/Users/ali/Documents/Projects/node
.js Experiments/app.js:9:23)
    at new Promise (<anonymous>)
    at smallAdd (/Users/ali/Documents/Projects/...

Oh but Node is not done shouting

(node:75085) UnhandledPromiseRejectionWarning:
Error: Cannot have b > 100
    at code (/Users/ali/Documents/Projects/node
.js Experiments/app.js:9:23)
    at new Promise (<anonymous>)
    at smallAdd (/Users/ali/Documents/Projects/...

(node:75085) UnhandledPromiseRejectionWarning:
Unhandled promise rejection. This error origina
ted either by throwing inside of an async funct
ion without a catch block, or by rejecting a pr
omise which was not handled with .catch(). (rej
ection id: 2)
(node:75085) [DEP0018] DeprecationWarning: Unha
ndled promise rejections are deprecated. In the
 future, promise rejections that are not handle
d will terminate the Node.js process with a non
-zero exit code.

Wait that looks familiar. But this time we have a .catch on the pendingAddResults?

The subtle thing about promise .then blocks is that they return a new promise. The catch block we added is attached to pendingAddResults, which is the original promise we constructed and returned from smallAdd. When we called .then on it, a new promise was returned which is what Node is complaining about.

Our refactored code should have been

And we only need to catch at the end of the promise chain, so not catching on pendingAddResults is okay here. ๐Ÿคจ

Now you begin to understand the complexity inherent in promise chains. While they allow asynchronous operations to execute in series, simplifying the happy path through the code, they create a much more complex error path, which if not thoughtfully handled leads to UnhandledPromiseRejection

Await

The last items I want to discuss are the async/await keywords. These can wreak even more havoc, because they too create runaway promises.

Let’s try them with our smallAdd code

We can do this because smallAdd returns a Promise and we can await any function that returns a Promise. Note we are not using the callback version of smallAdd here, we are firmly relying on the returned Promise.

When we run it we get this again

(node:75840) UnhandledPromiseRejectionWarning:
Error: Cannot have b > 100
    at code (/Users/ali/Documents/Projects/node
.js Experiments/app.js:9:23)
    at new Promise (<anonymous>)
    at smallAdd (/Users/ali/Documents/Projects/...

(node:75840) UnhandledPromiseRejectionWarning:
Unhandled promise rejection. This error origina
ted either by throwing inside of an async funct
ion without a catch block, or by rejecting a pr
omise which was not handled with .catch(). (rej
ection id: 3)
(node:75840) [DEP0018] DeprecationWarning: Unha
ndled promise rejections are deprecated. In the
 future, promise rejections that are not handle
d will terminate the Node.js process with a non
-zero exit code.

This is annoying. Okay so there is an error raised, which was expected, but why is it wrapped around an UnhandledPromiseRejection and is not just the actual error that was raised.

The cause is the run function, which is declared as async. This makes calling run return a Promise and guess what we have no catch where run is being called.

The correct way to call the async function run would be

Which results in the error being handled correctly this time and the expected output

--- Print ---
Got an error Error: Cannot have b > 100
    at code (/Users/ali/Documents/Projects/node
.js Experiments/app.js:9:23)
    at new Promise (<anonymous>)
    at smallAdd (/Users/ali/Documents/Projects/...

async/await are simply another way of expressing a promise chain in code. Just because you declare a method async, you do not get a free pass to ignore the errors the promise chain creates. You still have to handle that error somewhere.

Conclusion

I’ve discussed some of the most common and confusing pitfalls I have seen developers encounter when working with callbacks and promises in Node. Often times you work in code that uses both. The addition of async/await bring a new challenge to the mix where developers simply make a function async and forget about the rest.

What is important to remember when working with Node or any other programming language is that the error path is important. The happy path is usually carefully designed with great thought put into it. The error path just sort of happens as a side effect and in Node, due to its asynchronous nature, this can cause confounding errors, or even errors to be silently lost.

Debugging such errors is frustrating and causes a loss of productivity in teams. So make sure you are thinking carefully how your errors bubble up and promise yourself you will handle them gracefully.

Code

This is the complete listing of the code used in this article

'use strict'

function noop() {}

function smallAdd(a, b, callback = noop) {
    return new Promise(function code(resolve, reject) {

        if (b > 100) {
            let err = new Error('Cannot have b > 100');
            process.nextTick(() => callback(err));

            return reject(err);
        }

        let result = a + b;

        process.nextTick(() => callback(null, result));
        return resolve(result);
    });
}

function print(err, result) {

    console.log('--- Print ---');
    if (err) {
        console.log('Got an error', err);
        return;
    }

    console.log('Got our results', result);
}


smallAdd(3, 18, print).catch(noop);

//smallAdd(3, 108, print).catch(noop);

/*
smallAdd(3, 18)
    .then(result => print(null, result))
    .catch(err => print(err));

smallAdd(3, 108)
    .then(result => print(null, result))
    .catch(err => print(err));
*/

/*
smallAdd(3, 108)
    .then(result => print(null, result))
    .catch(err => print(err));
*/

/*
let pendingAddResults = smallAdd(3, 108)

let thenPromise = pendingAddResults.then(result => print(null, result));
thenPromise.catch(err => print(err));
*/

/*
async function run() {
    await smallAdd(2, 108);
}

run().catch(print);
*/

Image Credits

Cat Photo by FuYong Hua