Javascript Promises. In my mind I have an uneasy truce with them. The war ended primarily because I’ve been forced to use promises on contracts. Since then, standards bodies seem to have agreed to make them the one true asynchronous programming method. And if you’re writing code between layers which provide and expect promises, I suppose it’s time to do as the Romans.
I’m still uneasy. Why, you ask?
First, an exercise based on this recent tweet showing code using the new promise-based standard fetch API:
function fetchJSON(options, cb) {
fetch(options)
.then(res => res.json())
.then(json => cb(null, json), err => cb(err));
}
In your mind, tell me about this code. What is it trying to do? Is it correct? In all scenarios? Go ahead, take some time.
It is sound code, but only for the success case. If no exceptions are thrown by the success-case cb()
call tree before it next defers to the event loop, everything will be fine.
However, if, deep into the synchronous call tree started by that success-case cb()
, an exception is thrown, it will not hit any registered top-level window or node.js error handlers. It will be captured by the try/catch statement inside the implementation of then().
And because nothing is handling errors past that second then()
statement, the promise library will emit something like Bluebird’s ‘possibly unhandled exception’ to the console.
What’s the right solution? This code will fully break out of promises, putting all of your code back into a callback-only context. It defers to the event loop with setTimeout
, escaping all lingering Promise-provided try/catch blocks:
function fetchJSON(options, cb) {
fetch(options)
.then(res => res.json())
.then(function(json) {
setTimeout(() => cb(null, json), 0)
})
.catch(function(err) {
setTimeout(() => cb(err), 0);
});
}
Ugly? Yes. Don’t write this code if you don’t have to. If you’re using Bluebird (which I would recommend), use its built-in to- and from-callback conversions. Sadly, the new standard Promise implementations don’t include these helper methods, which will make these kinds of mistakes more common. Which brings me to…
Promises are billed as a simplification of asynchronous programming. Callbacks are painful, promises are the solution. But really promises layer complexity on top of a relatively simple initial system.
There’s no way around it. You need to understand the event loop and callback style if you’re using javascript. Just about all APIs are still written this way - all low-level Node.js APIs, for example. And you need to know how to do it right. If you don’t use callbacks properly, your web app or node.js application could crash immediately on a programming error. You’ll always have err
passed as the first parameter to these callbacks, so you’ll be watching for that kind of method signature.
Now we add promises to our application. Maybe long chains of promises are marginally easier to reason about than deep async.waterfall()
or async.series()
constructs. But now we have more complexity and new failure modes. As we saw above, though it looked like the code was error-handling properly, errors can be swallowed completely and your reporting systems won’t hear about it. Errors are propagated differently with promises, and you’ll need to add these new behaviors as a new mode in your mind.
I prefer to think of promises as a powerful but complex tool for composability. Callback style requires the client callback to be available when the async operation finishes. Now imagine the code you’d have to write to allow time-shifting of that asynchronous result. That’s promises. It’s a more complex system bolted on top of callbacks, so a result can be passed around and used by multiple clients whenever they are ready for it.
When architecting systems, we must ask ourselves: is the additional complexity worth it? I think things like redux-promise-middleware are pretty cool. Passing promises around can allow for more declarative versus imperative design, and that makes for cleaner, more predictable architecture.
Say you’ve decided to go with promises in your app. Here are a few tips to make that experience a bit nicer:
cb
in the argument list made it clear..spread()
- Determine a standard approach for your project and stick with it. Either multiple parameters with spread()
or manually handling of resolved values. Consider using ‘named parameters’ via objects, avoiding spread()
entirely.reject()
real Error
objects only - Just like callback-style, rejections should always be real error objects.return
statements - Where with callbacks you could just cb()
anywhere, now return statements should be in every function. Methods no longer take callbacks, so they must return promises. Inside a then()
, you can return plain objects and that becomes the final resolved value for the promise, no need for a Promise.resolve(x)
wrapper.catch()
, only at the top-level - Say you’re calling a Promise-based API from an Express endpoint handler. Your error handling will look like this: .catch(next)
to call the registered express error handler. Unless you have a very good reason, there should only be one catch()
call in the entire call tree, at the top. It’s very easy to make mistakes here once your error-handling gets any more complex..then()
calls. Refactor into small units. Also: take a look at my post on async composition.Now, go forth and be a productive engineer without any Holy Grail pretense. No library will solve all of your problems. Every new dependency simply means some level of benefit combined with costs to understand and maintain it over time. Better make sure the tradeoff is worth it.
Additional reading:
async
/await
in ES2016 requires you to understand promises. Also, another example of code not properly converting back to pure callbacks… https://medium.com/@bluepnume/learn-about-promises-before-you-start-using-async-await-eb148164a9c8#.yh55d1790asCallback()
here: https://github.com/petkaantonov/bluebird/blob/7a39370ba1b98da0aaef1fa9d85b2fd5daaba4ee/src/nodeify.js. Interesting that it still calls your node-style callback in a try/catch then throws it later.I’ve worked with quite a few large companies over the years, and some very clear patterns have emerged regarding change. It’s hard. It’s a big deal to switch over to new development technologies... Read more »
I had a nickname in my family when I was very young: “Bu’why.” I got it because I would ask ‘but why?’ so very often of the people around me. Most of the time they’d attempt to answer, but their... Read more »