Learn JavaScript promises in about 70 minutes

by qntm

Because twenty-five other explanations weren't enough, here I will explain JavaScript promises. This tutorial is intended for people who already understand JavaScript.

Previous educational essays of mine are Perl in 2 hours 30 minutes and regular expressions in 55 minutes.

Contents

The problem

JavaScript is single-threaded. It will only ever do at most one thing at once. There is no concurrency.

For example, if x is a global variable of some kind, then it is impossible for the value of x to change between these two lines of code:

console.log(x);
console.log(x);

This is because there are no other threads which could modify x while these two lines are executing.

*

JavaScript is event-based. It maintains a queue of messages. "A function is associated with each message. When the [call] stack is empty, a message is taken out of the queue and processed. The processing consists of calling the associated function (and thus creating an initial stack frame). The message processing ends when the stack becomes empty again."

We do not have the ability to inspect the queue, reorder it or remove messages from it.

Typically in JavaScript a message takes (or should take) an extremely small amount of time to process, of the order of milliseconds. This ensures that the application continues to appear responsive. If a message takes a very long time to process, or forever:

while(true) { }

then no other messages can be processed, and the application becomes unresponsive. Commonly this leaves us with no choice but to kill the application entirely (e.g. by closing the browser tab).

*

Now suppose we want to make a HTTP request. What we would like to write is something like this:

// BAD CODE DO NOT USE
var xhr = new XMLHttpRequest();
xhr.open("GET", "some/resource.json", false);
xhr.send();

console.log(xhr.responseText);

This is how we make a synchronous HTTP request. The call to xhr.send() blocks until the HTTP request is completed, after which we are free to inspect the XMLHttpRequest object to see the response.

But if we do this, then every other message gets blocked up behind this HTTP request. If the server takes a noticeable amount of time to respond, then the application stutters. If the server never responds, then the application locks up forever. Code like this is strongly discouraged due to the negative effect it has on the user experience.

Instead, we must write code asynchronously.

// GOOD CODE USE THIS INSTEAD
var xhr = new XMLHttpRequest();

xhr.addEventListener("load", function() {
	console.log(this.responseText);
});

xhr.open("GET", "some/resource.json");
xhr.send();

Here, the xhr.send() call returns immediately, and JavaScript continues working, while carrying out the real HTTP request in the background (i.e. on a thread to which we, the JavaScript programmer, do not have programmatic access). This is called non-blocking I/O.

Later, when (if) the HTTP request is completed, a new message will be placed in the message queue. The message will be associated with that listener function:

function() {
	console.log(this.responseText);
}

and, when JavaScript eventually reaches that message in the queue, the function will be called.

*

Now, how can we turn this HTTP request-making code into a callable function? What we want to write in our calling code is something like:

var responseText = get("some/resource.json");

But neither of these approaches works:

var get = function(resource) {
	var xhr = new XMLHttpRequest();
	xhr.addEventListener("load", function() {
		return this.responseText; // this value goes nowhere
	});
	xhr.open("GET", resource);
	xhr.send();
	return xhr.responseText; // returns `undefined`
};

The solution is callbacks. When calling get, we pass in our URL but also a callback function. When — at some nebulous future time — get has finished its task, it will call that function, passing the result in.

var get = function(resource, callback) {
	var xhr = new XMLHttpRequest();
	xhr.addEventListener("load", function() {
		callback(this.responseText);
	});
	xhr.open("GET", resource);
	xhr.send();
};

Usage:

get("some/resource.json", function(responseText) {
	console.log(responseText);
});

This is called continuation-passing style. As a general pattern, this works well, but it is not very pretty. It means that all later code has to be placed inside, or called from, that callback.

*

Interestingly, this pattern can even find use in functions which would not normally be asynchronous. A function like:

var parseJson = function(json) {
	var obj = JSON.parse(json);
	return obj;
};

becomes:

var parseJson = function(json, callback) {
	setTimeout(function() {
		var obj = JSON.parse(json);
		callback(obj);
	}, 0);
};

Here, setTimeout() is simply putting a message on the queue right away, without any delay. Although the interval specified is 0 milliseconds, this does not mean that

function() {
	var obj = JSON.parse(json);
	callback(obj);
}

will be invoked immediately; other messages which have been queued up in the meantime will be handled first. We might do this if we are carrying out a long single calculation and we want to break it up into smaller messages.

Note that parseJson no longer explicitly returns anything, which is another way of saying that it returns undefined. The same is true of get and any other function employing this pattern.

*

This pattern of callbacks becomes troublesome when we need to carry out several tasks in sequence:

get("some/resource.json", function(responseText) {
	parseJson(responseText, function(obj) {
		extractFnord(obj, function(fnord) {
			console.log(fnord);
		});
	});
});

Note the increasingly severe indentation. This is called callback hell.

The worse problem

How do we handle errors in this scenario? There are several ways to do this, but they both tend to make the situation even less readable.

Asynchronous error handling technique 1

One technique for handling errors is this:

var get = function(resource, callback) {
	var xhr = new XMLHttpRequest();
	xhr.addEventListener("load", function() {
		if(this.status === 200) {
			callback(undefined, this.responseText);
		} else {
			callback(Error());
		}
	});
	xhr.addEventListener("error", function() {
		callback(Error());
	});
	xhr.open("GET", resource);
	xhr.send();
};

And:

var parseJson = function(json, callback) {
	setTimeout(function() {
		try {
			var obj = JSON.parse(json);
			callback(undefined, obj);
		} catch(e) {
			callback(e);
		}
	}, 0);
};

Here we pass two arguments to the callback, the first of which is an error. Customarily, if nothing goes wrong, then err is falsy.

Usage:

get("some/resource.json", function(err1, responseText) {
	if(err1) {
		console.error(err1);
		return;
	}
	parseJson(responseText, function(err2, obj) {
		if(err2) {
			console.error(err2);
			return;
		}
		extractFnord(obj, function(err3, fnord) {
			if(err3) {
				console.error(err3);
				return;
			}
			console.log(fnord);
		});
	});
});

This approach is seen very commonly. As a prime example, take a look at the fs module in Node.js, which carries out filesystem operations, another form of I/O. Compare this blocking I/O function:

var str = fs.readFileSync("example.txt", "utf8");
console.log(str);

with this non-blocking version:

fs.readFile("example.txt", "utf8", function(err, str) {
	if(err) {
		console.error(err);
		return;
	}
	console.log(str);
});

Asynchronous error handling technique 2

The other way to handle errors is to pass two callbacks into the calculation. One is for success, and the other is for failure:

var get = function(resource, callback, errback) {
	var xhr = new XMLHttpRequest();
	xhr.addEventListener("load", function() {
		if(this.status === 200) {
			callback(this.responseText);
		} else {
			errback(Error());
		}
	});
	xhr.addEventListener("error", function() {
		errback(Error());
	});
	xhr.open("GET", resource);
	xhr.send();
};

And:

var parseJson = function(json, callback, errback) {
	setTimeout(function() {
		try {
			var obj = JSON.parse(json);
			callback(obj);
		} catch(e) {
			errback(e);
		}
	}, 0);
};

Usage:

get("some/resource.json", function(responseText) {
	parseJson(responseText, function(obj) {
		extractFnord(obj, function(fnord) {
			console.log(fnord);
		}, function(err3) {
			console.error(err3);
		});
	}, function(err2) {
		console.error(err2);
	});
}, function(err1) {
	console.error(err1);
});

This is marginally better, but still pretty ghastly. Both approaches have drawbacks and some inflexibility, as well as bloating argument lists. There must be a better way!

Introducing promises

Let's do some refactoring. Rewrite get like so:

var get = function(resource) {
	return new Promise(function(resolve, reject) {
		var xhr = new XMLHttpRequest();
		xhr.addEventListener("load", function() {
			if(this.status === 200) {
				resolve(this.responseText);
			} else {
				reject(Error());
			}
		});
		xhr.addEventListener("error", function() {
			reject(Error());
		});
		xhr.open("GET", resource);
		xhr.send();
	});
};

And similarly parseJson:

var parseJson = function(json) {
	return new Promise(function(resolve, reject) {
		setTimeout(function() {
			try {
				var obj = JSON.parse(json);
				resolve(obj);
			} catch(e) {
				reject(e);
			}
		}, 0);
	});
};

The asynchronous functions no longer accept callbacks, either for success or for failure. But they do return something now. The object returned is called a promise. The promise represents the task which get or parseJson (or extractFnord) has promised to do. It has three states: pending, fulfilled or rejected.

A promise starts out pending. The inner function(resolve, reject) { ... }, which we supply, is called the executor callback. The promise calls this function, passing in two arguments, resolve and reject. When the task has been done, we call resolve to fulfill the promise i.e. mark the task as completed. Or, if the task has failed, we call reject to reject the promise i.e. mark the task as failed. A promise which is fulfilled or rejected is called settled.

By interacting with this promise object, we can register success and error callbacks to be called when it settles. But first, some bullet points!

Most of these little guarantees just serve to make promises simpler and more robust in their behaviour. Notice that there were no such guarantees in our original asynchronous functions definitions; there was nothing to stop us from, say, calling callback twice with two different values. Promises protect us from such weirdness and from the boilerplate code which would be required to manually handle such weirdness.

And also introducing then()

Now here's a very important point: there is no way to directly inspect the current state of a promise, or the value it fulfilled with (if any), or the error it rejected with (if any).

Instead, we call then(callback, errback) on a promise to register a success callback and an error callback. And so our usage now looks like this:

get("some/resource.json").then(function(responseText) {
	parseJson(responseText).then(function(obj) {
		extractFnord(obj).then(function(fnord) {
			console.log(fnord);
		}, function(err3) {
			console.error(err3);
		});
	}, function(err2) {
		console.error(err2);
	});
}, function(err1) {
	console.error(err1);
});

This still isn't great, but we're gradually getting closer to something good. First, more bullet points:

Promise chaining

Calling then(callback, errback) on a promise returns a new promise. The way in which the new promise settles depends on two things:

  1. The way in which the first promise settles.
  2. What happens inside the success or error callback.

Here are some examples.

*

In a success or error callback, we can return or throw nearly any value we like. If we don't return anything, this is the same as returning undefined, so the fulfilled value of the new promise is undefined, which is fine. There's one special case, which we may remember from earlier:

If we fulfill a promise with a second promise, the first promise settles the same way as the second

This applies no matter what method we use to fulfill that first promise, be it Promise.resolve():

Promise.resolve("foo")                                   // fulfills with "foo"
Promise.reject("bar")                                    // rejects with "bar"
Promise.resolve(Promise.resolve("foo"))                  // fulfills with "foo"
Promise.resolve(Promise.reject("bar"))                   // rejects with "bar"
Promise.resolve(Promise.resolve(Promise.resolve("foo"))) // fulfills with "foo"
Promise.resolve(Promise.resolve(Promise.reject("bar")))  // rejects with "bar"
// and so on

Or the executor callback:

new Promise(function(resolve, reject) { resolve("foo"); })                  // fulfills with "foo"
new Promise(function(resolve, reject) { reject("bar"); })                   // rejects with "bar"
new Promise(function(resolve, reject) { resolve(Promise.resolve("foo")); }) // fulfills with "foo"
new Promise(function(resolve, reject) { resolve(Promise.reject("foo")); })  // rejects with "bar"
// and so on

Or a success or error callback:

Promise.resolve().then(function() { return "foo"; })                  // fulfills with "foo"
Promise.resolve().then(function() { throw "bar"; })                   // rejects with "bar"
Promise.resolve().then(function() { return Promise.resolve("foo"); }) // fulfills with "foo"
Promise.resolve().then(function() { return Promise.reject("bar"); })  // rejects with "bar"
// and so on

(Note that it's totally okay to reject a promise with a second promise:

Promise.reject(Promise.resolve("foo"))                                      // rejects with `Promise.resolve("foo")`
new Promise(function(resolve, reject) { reject(Promise.resolve("foo")); }); // rejects with `Promise.resolve("foo")`
Promise.resolve().then(function() { throw Promise.resolve("foo"); })        // rejects with `Promise.resolve("foo")`
// and so on

But this is a rather odd thing to do...)

*

Why is this so significant?

Because it means that we can asynchronously transform values as well. Which allows us to suddenly turn this code:

get("some/resource.json").then(function(responseText) {
	parseJson(responseText).then(function(obj) {
		extractFnord(obj).then(function(fnord) {
			console.log(fnord);
		}, function(err3) {
			console.error(err3);
		});
	}, function(err2) {
		console.error(err2);
	});
}, function(err1) {
	console.error(err1);
});

into this:

get("some/resource.json").then(function(responseText) {
	return parseJson(responseText);
}).then(function(obj) {
	return extractFnord(obj);
}).then(function(fnord) {
	console.log(fnord);
}).catch(function(err) {
	console.error(err);
});

And boom! We're out of callback hell!

*

Let's break our new asynchronous code down. There are five promises in the main chain.

*

One more thing. Since the callback functions are guaranteed to be called with only a single argument, and the value of this passed will be undefined, and our intermediate functions parseJson and extractFnord don't use this internally, our code may be simplified even further:

get("some/resource.json")
	.then(parseJson)
	.then(extractFnord)
	.then(function(fnord) {
		console.log(fnord);
	}).catch(function(err) {
		console.error(err);
	});

In some browsers, console.log and console.error aren't sensitive to the value of this either, so we can even go as far as:

get("some/resource.json")
	.then(parseJson)
	.then(extractFnord)
	.then(console.log)
	.catch(console.error);

Amazing!

To take maximum advantage of this pattern, write functions (and methods!) which

  1. Accept only a single argument
  2. Do not use this (or, use this but also use bind() to fix its value)
  3. Return a promise

*

So here's a fun edge case. If we fulfill a promise with a second promise, the first promise settles the same way as the second. What if the second promise is the first promise?

var p = new Promise(function(resolve, reject) {
	setTimeout(function() {
		resolve(p);
	}, 0);
});

(Note that setTimeout must be used here, since the executor callback is called synchronously at Promise construction time, at which time the value of p is still undefined.)

Answer: this promise rejects with a TypeError because of the cycle that has been introduced.

Let's use this to segue into the topic of error handling.

Error handling in a promise chain

If a promise rejects, execution passes to the next available error callback. To demonstrate how this works, we'll start with a basic promise chain:

Promise.resolve("foo").then(function(str) {
	return str + str;
}).then(function(str) {
	console.log(str);
}, function(err) {
	console.error(err);
});

and see what happens if we introduce errors — which is to say, cause promises to reject — at various points.

This code:

Promise.reject("bar").then(function(str) {
	return str + str;
}).then(function(str) {
	console.log(str);
}, function(err) {
	console.error(err);
});

hits none of the success callbacks, and immediately errors out printing "bar".

This code:

Promise.resolve("foo").then(function(str) {
	throw str + str;
}).then(function(str) {
	console.log(str);
}, function(err) {
	console.error(err);
});

hits the first success callback, then errors out printing "foofoo". The second success callback is not hit.

And finally, this code:

Promise.resolve("foo").then(function(str) {
	return str + str;
}).then(function(str) {
	throw str;
}, function(err) {
	console.error(err);
});

prints nothing at all!

Remember: when we use then(callback, errback), either the success callback or the error callback will be called, never both. errback does not handle exceptions thrown by callback. callback and errback are both intended to handle the outcome from the previous promise, not from each other.

Because of this potential for "leaking" an error, I think it is good practice to never call then(callback, errback), passing in both callbacks. It's safer to always use then(callback), passing in only one callback, or, when handling errors at the tail end of a chain, to use catch(errback):

Promise.resolve("foo").then(function(str) {
	return str + str;
}).then(function(str) {
	throw str;
}).catch(function(err) {
	console.error(err);
});

And in general, we should always conclude a promise chain with a catch() call, because otherwise errors in the chain will disappear and never be detected.

Of course, if an exception is thrown during an error callback, then we may be out of luck entirely, but that's a standing problem with all of error handling...

Implementation variations

Throughout this document we have been using the native Promise implementation which is present in many JavaScript engines. This is a relatively new feature of JavaScript and does not have universal support; in particular, it is not available in Internet Explorer. However, there are numerous third-party implementations of promises which work in a basically identical way, such as Q and Bluebird.

All these implementations conform to a technical specification called Promises/A+. This specification only really specifies the behaviour of the then() method. The then() method will work identically in all conforming implementations.

Everything else is left up to implementers. For example, the APIs for:

are not specified by Promises/A+. The native Promise implementation specified in ES6 works like this, and other implementations generally work similarly, but these APIs are not necessarily universal.

Different implementations are also at liberty to offer whatever additional functionality they wish. Promise offers two other methods worth mentioning, Promise.all() and Promise.race(), both of which accept an array of promises run "in parallel". Promise.all() fulfills with an array containing the fulfilled values from all the inner promises, Promise.race() fulfills with the value of the first inner promise to fulfill. Other implementations usually offer equivalent functionality and often offer much more functionality.

Summary of promise settling behaviour

Promise Attempted settle method Value/error New state Value/error
new Promise(function(resolve, reject) { resolve( "foo" ); }) Fulfilled "foo"
new Promise(function(resolve, reject) { resolve( Promise.resolve("X") ); }) Fulfilled "X"
new Promise(function(resolve, reject) { resolve( Promise.reject("Y") ); }) Rejected "Y"
new Promise(function(resolve, reject) { reject( "foo" ); }) Rejected "foo"
new Promise(function(resolve, reject) { reject( Promise.resolve("X") ); }) Rejected Promise.resolve("X")
new Promise(function(resolve, reject) { reject( Promise.reject("Y") ); }) Rejected Promise.reject("Y")
new Promise(function(resolve, reject) { return  "foo" ; }) Pending none
new Promise(function(resolve, reject) { return  Promise.resolve("X") ; }) Pending none
new Promise(function(resolve, reject) { return  Promise.reject("Y") ; }) Pending none
new Promise(function(resolve, reject) { throw  "foo" ; }) Rejected "foo"
new Promise(function(resolve, reject) { throw  Promise.resolve("X") ; }) Rejected Promise.resolve("X")
new Promise(function(resolve, reject) { throw  Promise.reject("Y") ; }) Rejected Promise.reject("Y")
Promise.resolve().then(function() { resolve( "foo" ); }) Rejected ReferenceError("resolve is not defined")
Promise.resolve().then(function() { resolve( Promise.resolve("X") ); }) Rejected ReferenceError("resolve is not defined")
Promise.resolve().then(function() { resolve( Promise.reject("Y") ); }) Rejected ReferenceError("resolve is not defined")
Promise.resolve().then(function() { reject( "foo" ); }) Rejected ReferenceError("reject is not defined")
Promise.resolve().then(function() { reject( Promise.resolve("X") ); }) Rejected ReferenceError("reject is not defined")
Promise.resolve().then(function() { reject( Promise.reject("Y") ); }) Rejected ReferenceError("reject is not defined")
Promise.resolve().then(function() { return  "foo" ; }) Fulfilled "foo"
Promise.resolve().then(function() { return  Promise.resolve("X") ; }) Fulfilled "X"
Promise.resolve().then(function() { return  Promise.reject("Y") ; }) Rejected "Y"
Promise.resolve().then(function() { throw  "foo" ; }) Rejected "foo"
Promise.resolve().then(function() { throw  Promise.resolve("X") ; }) Rejected Promise.resolve("X")
Promise.resolve().then(function() { throw  Promise.reject("Y") ; }) Rejected Promise.reject("Y")

Conclusion

I find promises to be heck of complicated and it wasn't until I sat down and tried to write this that I realised just how poorly I understood them. Not pictured here is the lengthy interlude where I gave up and, as a learning exercise, attempted to read, understand and implement the Promises/A+ specification myself, which nearly caused my head to explode. Anyway, I think I have a pretty good handle on them now, and I hope you do too.

Back to Things Of Interest