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