A Promise
is an object representing the eventual completion or failure of an asynchronous operation. A promise may be created using its constructor. However, most people are consumers of already-created promises returned from functions. This guide will therefore explore consumption of returned promises first.
Essentially, a promise is a returned object you attach callbacks to, instead of passing callbacks into a function.
E.g., instead of an old-style function that expects two callbacks, and calls one of them on eventual completion or failure:
function successCallback(result) {
console.log("It succeeded with " + result);
}
function failureCallback(error) {
console.log("It failed with " + error);
}
doSomething(successCallback, failureCallback);
…modern functions return a promise you can attach your callbacks to instead:
let promise = doSomething();
promise.then(successCallback, failureCallback);
…or simply:
doSomething().then(successCallback, failureCallback);
We call this an asynchronous function call. This convention has several advantages. We will explore each one.
Guarantees
Unlike old-style passed-in callbacks, a promise comes with some guarantees:
- Callbacks will never be called before the completion of the current run of the JavaScript event loop.
- Callbacks added with .then even after the success or failure of the asynchronous operation, will be called, as above.
- Multiple callbacks may be added by calling .then several times, to be executed independently in insertion order.
But the most immediate benefit of promises is chaining.
Chaining
A common need is to execute two or more asynchronous operations back to back, where each subsequent operation starts when the previous operation succeeds, with the result from the previous step. We accomplish this by creating a promise chain.
Here's the magic: the then
function returns a new promise, different from the original:
const promise = doSomething(); const promise2 = promise.then(successCallback, failureCallback);
or
let promise2 = doSomething().then(successCallback, failureCallback);
This second promise represents the completion not just of doSomething()
, but also of the successCallback
or failureCallback
you passed in, which can be other asynchronous functions returning a promise. When that's the case, any callbacks added to promise2
get queued behind the promise returned by either successCallback
or failureCallback
.
Basically, each promise represents the completion of another asynchronous step in the chain.
In the old days, doing several asynchronous operations in a row would lead to the classic callback pyramid of doom:
doSomething(function(result) { doSomethingElse(result, function(newResult) { doThirdThing(newResult, function(finalResult) { console.log('Got the final result: ' + finalResult); }, failureCallback); }, failureCallback); }, failureCallback);
With modern functions, we attach our callbacks to the returned promises instead, forming a promise chain:
doSomething().then(function(result) { return doSomethingElse(result); }) .then(function(newResult) { return doThirdThing(newResult); }) .then(function(finalResult) { console.log('Got the final result: ' + finalResult); }) .catch(failureCallback);
The arguments to then
are optional, and catch(failureCallback)
is short for then(null, failureCallback)
. You might see this expressed with arrow functions instead:
doSomething() .then(result => doSomethingElse(result)) .then(newResult => doThirdThing(newResult)) .then(finalResult => { console.log(`Got the final result: ${finalResult}`); }) .catch(failureCallback);
Important: Always return promises up, otherwise callbacks won't chain, and errors won't be caught.
Chaining after a catch
It's possible to chain after a failure, i.e. a catch
, which is useful to accomplish new actions even after an action failed in the chain. Read the following example:
new Promise((resolve, reject) => { console.log('Initial'); resolve(); }) .then(() => { throw new Error('Something failed'); console.log('Do this'); }) .catch(() => { console.log('Do that'); }) .then(() => { console.log('Do this whatever happened before'); });
This will output the following text:
Initial Do that Do this whatever happened before
Note that the text Do this
is not outputted because the Something failed
error caused a rejection.
Error propagation
You might recall seeing failureCallback
three times in the pyramid of doom earlier, compared to only once at the end of the promise chain:
doSomething() .then(result => doSomethingElse(value)) .then(newResult => doThirdThing(newResult)) .then(finalResult => console.log(`Got the final result: ${finalResult}`)) .catch(failureCallback);
Basically, a promise chain stops if there's an exception, looking down the chain for catch handlers instead. This is very much modeled after how synchronous code works:
try { let result = syncDoSomething(); let newResult = syncDoSomethingElse(result); let finalResult = syncDoThirdThing(newResult); console.log(`Got the final result: ${finalResult}`); } catch(error) { failureCallback(error); }
This symmetry with synchronous code culminates in the async
/await
syntactic sugar in ECMAScript 2017:
async function foo() { try { let result = await doSomething(); let newResult = await doSomethingElse(result); let finalResult = await doThirdThing(newResult); console.log(`Got the final result: ${finalResult}`); } catch(error) { failureCallback(error); } }
It builds on promises, e.g. doSomething()
is the same function as before. You can read more about the syntax here.
Promises solve a fundamental flaw with the callback pyramid of doom, by catching all errors, even thrown exceptions and programming errors. This is essential for functional composition of asynchronous operations.
Creating a Promise around an old callback API
A Promise
can be created from scratch using its constructor. This should be needed only to wrap old APIs.
In an ideal world, all asynchronous functions would already return promises. Alas, some APIs still expect success and/or failure callbacks to be passed in the old way. The quintessential example is the setTimeout()
function:
setTimeout(() => saySomething("10 seconds passed"), 10000);
Mixing old-style callbacks and promises is problematic. If saySomething
fails or contains a programming error, nothing catches it.
Luckily we can wrap it in a promise. Best practice is to wrap problematic functions at the lowest possible level, and then never call them directly again:
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); wait(10000).then(() => saySomething("10 seconds")).catch(failureCallback);
Basically, the promise constructor takes an executor function that lets us resolve or reject a promise manually. Since setTimeout
doesn't really fail, we left out reject in this case.
Composition
Promise.resolve()
and Promise.reject()
are shortcuts to manually create an already resolved or rejected promise respectively. This can be useful at times.
Promise.all()
and Promise.race()
are two composition tools for running asynchronous operations in parallel.
Sequential composition is possible using some clever JavaScript:
[func1, func2].reduce((p, f) => p.then(f), Promise.resolve());
Basically, we reduce an array of asynchronous functions down to a promise chain equivalent to: Promise.resolve().then(func1).then(func2);
In ECMAScript 2017, sequential composition can be done more simply with async/await:
for (let f of [func1, func2]) { await f(); }
Timing
To avoid surprise, functions passed to then
will never be called synchronously, even with an already-resolved promise:
Promise.resolve().then(() => console.log(2)); console.log(1); // 1, 2
Instead of running immediately, the passed-in function is put on a microtask queue, which means it runs later when the queue is emptied at the end of the current run of the JavaScript event loop, i.e. pretty soon:
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); wait().then(() => console.log(4)); Promise.resolve().then(() => console.log(2)).then(() => console.log(3)); console.log(1); // 1, 2, 3, 4