Async Javascript and Javascript promises
Posted by Kenan Rhoton
If you've been a part of any kind of medium to large projects using JavaScript, you've had to dealt with asynchronicity in one of the many flavors it comes in.
Most of us have a hard time with it at first, we are used to writing code that works mostly sequentially, like if we write:
doSomething();
doOtherThing();
thingItOn();
we tend to assume the order the functions are called is the order they will be executed, resolve and return. Such is not the way of async.
If all these functions are asynchronous, it means we cannot predict the order they will execute or resolve. Maybe the first one will be done in 10 seconds after a request timeout. Maybe the second one will take just 5 milliseconds to read something from disk. And so, in the world of software development based on controlling a computer to make it do exactly what we want it to do, we have introduced chaos.
Or have we?
Events, the first approach to async Javascript
If we go back in the history of asynchronous operations, we will find event systems in its inception. Or, more specifically, interrupt systems.
An interrupt system is, in basic terms, a set of functions your operating system may call at any time in response to something, interrupting the current execution.
Examples of this would be the functions that handle key presses, mouse movement, the clock or even a new USB port connection.
It's obviously impossible for the programmer to know ahead of time when these functions will be triggered.
We see something very similar in JavaScript. The obvious example is the onclick
, onchange
, etc. attributes that many elements can have. The less obvious one is how certain APIs (like XMLHttpRequest) register listeners for events so the developer can access the data.
let xhr = new XMLHttpRequest();
xhr.open("POST", '/server', true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
console.log(xhr.responseText);
}
}
xhr.send("foo=bar&lorem=ipsum");
Here we assign a function to onreadystatechange
which will be executed whenever the ready state changes (just as advertised). If you think this is kind of ugly and clunky, I agree, which is why...
Callbacks in asynchronous Javascript
Callbacks aren't a new idea. In fact, they literally predate computing. This very old idea of passing a function as a parameter is really just a cleaner API for the above event system, where you just pass a function to be executed "whenever you finish doing what you're doing".
The same concept for the above POST request could be represented as follows when using a callback API:
post({url: "/server", params: {foo: "bar", lorem: "ipsum"}}, function(result, err) {
if (err !== null) {
console.log(err);
} else {
console.log(result);
}
});
This is a lot nicer to use! Granted, maybe not as nice as it could be, but a lot easier to wrap your mind around: the function takes an object as its first parameter with all the options and a callback as its second parameter which will be called whenever the request is done, either successfully (with a null error) or unsuccessfully (with some error message).
This style was widely adopted in the language, but here's the secret: it really just uses events under the hood. It simply exposes them to the programmer in a much cleaner way.
So we can think of callbacks as registering a single event which will fire whenever anything happens with our request and call the passed function, which makes us lose some of the more fine-grained power, but realistically we almost never care about such granularity.
Callbacks, however, have a problem. Welcome to callback hell:
post({url: "/login", params: {user: "yo", pass: "dude"}}, function (result, err) {
if (err !== null) {
console.log(err);
} else {
get({url: "/tasks", params: {auth: result.token}}, function(result, err) {
if (err !== null) {
console.log(err);
} else {
result.tasks.filter((t) => t.tags.includes("sports"))
.forEach((t) => patch({url: `/task/${t.id}`, params: {complete: true}}, function (result, err) {
// At this point you can see
// this is getting silly
});
}
});
}
});
Yeah, nesting callbacks sucks, especially when you don't actually need to tell exactly where the failure was with as much detail. You get a bunch of indentation that's hard to follw. Generally, this was solved by defining named functions elsewhere and passing them as the callbacks, but now your logic is all over the place and you have a lot of functions that look something like this:
function getTasksAfterLoggingIn(result, err) {
if (err !== null) {
console.log(err);
} else {
get({url: "/tasks", params: {auth: result.token}}, completeSportsTasksAfterTaskList);
}
}
Good luck keeping track of all the functions which work in specific orders and no others. So for complex problems, callbacks seem to not work so well.
Thankfully, Promises happened.
Async Javascript: Javascript promises
JavaScript Promises have one simple premise: what if we could have all the benefits of callbacks and none of the drawbacks? Nice selling pitch. Lets first dive into how the above example would look with a Promise API:
post({url: "/login", params: {user: "yo", pass: "dude"}})
.then(function (result) {
return get({url: "/tasks", params: {auth: result.token}})
}).then(function(result) {
return Promise.all(result.tasks.filter((t) => t.tags.includes("sports"))
.map((t) => patch({url: `/task/${t.id}`, params: {complete: true}})))
}).catch(function(err) {
console.log(err);
});
Now we're getting somewhere! There's that funky Promise.all
call for the multiple-patch call, but other than that, it's pretty clean. So how does this even work? We first need to understand what a Promise is and how it's made:
new Promise(function (resolve, reject) {
setTimeout(function () {
if (getColor(user.profile) === "orange") {
resolve("Yes! Color is orange!");
} else {
reject("Infidel! Orange is the best color!");
}
}, 1000);
});
So a Promise is really just a JavaScript class which is itself really just an Object with some bling. What's interesting is what this class does under the hood: it provides and API to manage the actions to take after an async operation is completed, whether it's successfully or unsuccessfully.
The way it works is really simple: the object is created with a function parameter that takes two arguments, which are resolve and reject. These parameters are themselves functions which must be called on success or failure respectively. The object itself exposes two methods: then
and catch
.
The then
method takes a function argument and adds that function as a listener for the event triggered whenever resolve
is called, which will take whichever arguments resolve
is passed. The same goes for the catch
method and reject
. Simple, right? But there are a few details that make this special:
Returning a Promise from within
then
makesthen
return a new Promise which combines any of the previous Promises, allowing us to "chain" them.catch
is called if any Promise in the chain is rejected, which simplifies error handling.Similarly to
then
,catch
can also return a Promise to start a new Promise chain.
This allows all sorts of patterns that ease use of async operations:
asyncOp1()
.then((result) => asyncOp2(result))
.then((result) => asyncOp3(result))
.catch((err) => logFail(err))
.then((result) => alwaysDo());
And this is just the tip of the iceberg; you can use tools such as Promise.all
which takes an array of Promises as an argument and resolves only if all of the Promises resolve and rejects if any of them rejects.
Yet as programmers we always seem to want more. Despite the power of this API, it still requires you to think quite deliberately about async operations. Wouldn't it be amazing if we could just pretend async operations were... synchronous?
Async / Await in asynchronous Javascript
You may have heard of async/await syntax in the wild. It's mostly just syntactic sugar for promises but it can do wonders for legibility in the right cases, allowing you to use Promises in an almost synchronous fashion. For example:
async function getSportsTasks() {
const tasks = await get({url: "/tasks", params: {token: SECRET}});
return tasks.filter((t) => t.tags.includes("sports"))
}
is mostly equivalent to:
function getSportsTasks() {
return new Promise(function(resolve, reject) {
get({url: "/tasks", params: {token: SECRET}})
.then((tasks) => resolve(
tasks.filter((t) => t.tags.includes("sports))))
.catch((err) => throw new Error())
});
}
So async/await is mostly about making your code more readable, although once you need to actually manage the failure states somewhat specifically it gets uncomfortable, since instead of dealing with a rejection that gets handled in a catch, you have to actually surround the awaits with a try-catch block, like this:
async function getSportsTasks() {
let tasks;
try {
tasks = await get({url: "/tasks", params: {token: SECRET}});
} catch (err) {
console.log(err);
return []
}
return tasks.filter((t) => t.tags.includes("sports"))
}
The important rule to remember with async/await is this: you are only ever allowed to use await
directly within an async function, which means this is illegal:
async function doSomething() {
const subjects = await getSubjects();
subjects.forEach((subject) => {
// this next line is illegal
const students = await getStudents(subject);
console.log(students);
)};
}
Async Javascript? It's all just events
So remember:
Async/await is just syntactic sugar for Promises
Promises are just an improved API for callbacks
Callbacks are just a wrapper over events
Everything is actually just events!