In my previous post, I took you through an introduction and gave a peek under the hood for ES6 Promise
s, showing you how they work and how to use them. Today, I’m going to talk about how JavaScript Promise
s can break. Hopefully, this will equip you to track down Promise
bugs in code that fails in mysterious ways.
This is the 2nd is a series on JavaScript Promises:
- How They Work
- How They Break
- How They’ll Work Someday
Breaking the Chain
Here’s the chaining example from our last post, with a slight change that breaks it entirely. See if you can spot it:
fetchResult(query)
.then(function(result) {
processResult(result);
})
.then(function(processedResult) {
console.log('processed result', processedResult);
});
.catch(function(error) {
console.error(error);
});
What’s wrong with the above example? Line 3 is missing the return
. The way this changes the chain is subtle:
- When the
Promise
returned byfetchResult()
resolves, its.then()
is called. processResult()
is called, which returns aPromise
. Its returnedPromise
is never attached to anything, so it begins executing–but nobody will ever be notified of its result. This is especially insidious ifprocessResult()
is doing something like kicking off a database update—the update will almost certainly happen, even though your code won’t know about it.- The function containing
processResult()
will return nothing—undefined
–and automatically wrap it in a resolvedPromise
. - The next
.then()
will execute immediately, receiveundefined
asprocessedResult
, and output it.
As far as broken Promise
chains go, this one just throws away a result. But as any JavaScript programmer knows, undefined
floating around where a value is expected can cause all sorts of havoc.
To avoid this problem, you can adopt the pattern where every step in your chain returns a Promise
, either by creating one on the spot (hint: try Promise.resolve()
if you already have the value you want to return, or simply want to resolve that step without returning anything at all) or by returning the result of a Promise
-generating function. Then you’ll never have to guess whether a particular .then()
was supposed to return a value or not.
Personally, I think that letting .then()
implementations return non-Promise
values was a design mistake. Explicit intent is better than implicit behavior, and without the magic wrapping of bare values as an option, Promise
implementations could have thrown exceptions when a .then()
returned a non-Promise
. This would clearly show where errors were made and reduce the risk of weird behavior, at the expense of a little verbosity.
Jumping the Gun
You don’t always need to put a full inlined function declaration in a .then()
. In fact, it can dramatically improve readability if you factor out steps into named functions. Here’s that chain we’ve been working on again, using this strategy:
fetchResult(query)
.then(processResult)
.then(function(processedResult) {
console.log('processed result', processedResult);
});
.catch(console.error);
But be careful that you’re giving .then()
a function reference, and not a call. This
fetchResult(query)
.then(processResult())
.then(function(processedResult) {
console.log('processed result', processedResult);
});
.catch(console.error);
will behave very oddly. fetchResult()
will start first. But processResult()
will not only be called with no arguments; it will be called immediately, without even waiting for fetchResult()
to run.
This happens because we’re not giving .then()
a function that it can call on its own terms–we’re giving it the results of a function call. Regrettably, .then()
doesn’t flag this as any sort of error—it just passes undefined
to the next link in the chain.
The fact that Promise
-generating functions start their work immediately can confuse developers a bit in other ways, too:
fetchResult(query)
.then(processResult);
.then(console.log);
markProcessingDone(query)
.then(console.log);
Here, markProcessingDone()
does not wait for the fetchResult(query)
chain to complete. It will start immediately, running effectively in parallel with the first chain. This is a problem particularly if anything in the first chain fails—we’d be marking our processing as done, when in fact it failed.
Problems up Front
One really nice behavior of Promise
chains is that an unexpected exception thrown in .then()
s will be converted automatically to a rejected Promise
, which then filters through to a .catch()
.
Unfortunately, because Promise
s are not a language feature but an API, they can’t do a thing about the first link in the chain. Refer back to our all-in-one fetch and process function (which we’ve now simplified a bit as per the last section):
function fetchAndProcessResult(query) {
return fetchResult(query)
.then(processResult);
}
If fetchResult()
throws an exception, that exception is going to bubble up to the caller without returning a Promise
. Maybe that’s okay for your caller. Unfortunately, I’ve seen several examples where it’s not. If that’s the case, you can use this one weird trick:
function fetchAndProcessResult(query) {
return Promise.resolve(query)
.then(fetchResult)
.then(processResult);
}
This adds a little overhead in the form of an extra immediately-resolved Promise
, but it has the advantage that any exceptions raised in the call to fetchResult
will be caught and converted to a rejected Promise
, ready for our caller’s .catch()
.
JavaScript Promises Live Forever
One thing you’ll need to be very careful about when constructing your own Promise
s is to make absolutely sure you’ve covered all the possibilities and routed them to either resolve
or reject
. You’ll also need to watch out for unexpected paths your code can take, so keep it simple.
If you’re not confident that your new Promise
will ever be resolved or rejected, you might want to consider adding a timeout. Here’s an addition to the fetchResult()
function from the last installment, now with a five-second timeout:
function fetchResult(query) {
return new Promise(function(resolve, reject) {
…
setTimeout(function() {
reject(new Error('timed out'));
}, 5000);
…
});
}
This relatively straightforward pattern helps cover you when the code or the service you’re using might leave you hanging. It allows you to make sure your caller isn’t left hanging, too.
Go Forth and Promise
Some of these issues may seem a bit contrived. But despite Promise
s being our best option for asynchronous programming in JavaScript right now, their paradigm still causes mental friction. In turn, this can make even experienced developers make mistakes like these.
But there’s hope. In my next post, I’ll show you how some ES6 features can make Promise
s easier to use. I’ll also show you features from future versions of JavaScript that will make asynchronous programming as painless as possible, while still remaining compatible with code that uses Promise
s.
This is the 2nd is a series on JavaScript Promises:
- How They Work
- How They Break
- How They’ll Work Someday