JavaScript comes from a legacy of peril with asynchronous operations. It began with callbacks to make Ajax calls for partial page updates. The humble callback function worked but had gotchas like callback hell. Since then, JavaScript evolved into a modern language with Promises and async/await. In this take, we’ll show how advancements in ES2017 can make async code much better.
Think of these async features as improvements and not a replacement. These new features build on top of the humble callback function. What you already know about JavaScript is useful for adopting these new features. In JavaScript, it’s seldom the use of one feature versus another but a combination of the two.
To begin, we’ll build on top of this humble callback function:
const addByTwo = (x) => x + 2;
We’ll use ES6 arrow functions to make the code more succinct. This puts more focus on async operations.
Callbacks
The humble callback function has some advantages because it is simple. Deferring execution with a timeout, for example, is done this way:
setTimeout((n) => console.log(addByTwo(n)), 1000, 2);
The setTimeout
takes in a callback as a parameter and defers execution. This works well but what happens when there are multiple callbacks? Callbacks can depend on the result of each one which leads to the following:
setTimeout((p) =>
setTimeout((l) =>
setTimeout((n) =>
console.log(addByTwo(n)),
1000, addByTwo(l)),
1000, addByTwo(p)),
1000, 2);
This is what is often known as the pyramid of doom. Chained callback functions must be nested several levels. This makes the code brittle and hard to understand. As a quick exercise, imagine how hard it is to add one more async operation in this. To summarize this code, execution is deferred three seconds and the result is six.
Promises
Promises can make the above easier to work with. Start by abstracting the async operation in a Promise:
const fetchAddByTwoPromise = (p) => new Promise(
resolve => setTimeout((n) => resolve(addByTwo(n)), 1000, p));
For this example, we only care about the resolve
which executes the callback function. A parameter p
sets which number gets added by two.
With a Promise in place, it is now possible to do this:
fetchAddByTwoPromise(2)
.then((r) => fetchAddByTwoPromise(r))
.then((r) => fetchAddByTwoPromise(r))
.then((r) => console.log(r));
Note how clean this is, and maintainable. Code changes are simpler because you no longer care where it sits in the pyramid. The then
method can return a Promise if it’s to continue making async calls. In the end, the result goes in the console’s output.
The async journey does not end with Promises. ES2017 introduces async/await which builds on top of this concept.
Async/Await
To use async/await, it needs a function that returns a Promise. This function must be prefixed with async
before it can use await
. For this example, create an async function that returns a Promise<number>
:
const asyncAwaitExample = async (n) => {
};
Inside this async function, it can have the following:
let result = await fetchAddByTwoPromise(n);
result = await fetchAddByTwoPromise(result);
return await fetchAddByTwoPromise(result);
Note the code now reads more like synchronous code. Each await
returns a fulfilled Promise so it is building on top of the Promise abstraction. A let
allows the variable to be mutable and gets reused with each call. Adding more async operations is a simple matter of adding more lines of code.
To get the result, we can call the async function and check the returned Promise:
asyncAwaitExample(2).then((r) => console.log(r));
One way to see this is callbacks are the backbone of a Promise. And, a Promise is now the backbone of async/await. This is the beauty in modern JavaScript. You are not relearning the language but building on top of existing expertise.
Pitfalls
The code samples above take around three seconds to complete. This is because a Promise suspends execution until fulfilled. In async/await, the line of code doing the await
suspends execution in the same manner. For this particular use case, the result
is valuable because it is a dependency of the overall result. This makes it to where the code cannot run in parallel because of this dependency.
In cases where there are no dependencies between async operations. There might be an opportunity to run everything in parallel. This speeds up execution since it’s not having to wait.
This is where both a Promise and async/await can work together:
const pitfallExample = async(n) => {
return await Promise.all([
fetchAddByTwoPromise(n),
fetchAddByTwoPromise(n),
fetchAddByTwoPromise(n)]);
};
Because each async operation fires at the same time, overall runtime is down to one second. Combining both a Promise and async/await makes the code more readable. Keep this in mind when working with async code, no need to make customers wait longer than they should.
To fire up this async function, do:
pitfallExample(2).then((r) => console.log(r.reduce((x, y) => x + y)));
Note Promise.all
returns an array of the results. Each async operation result that ran in parallel will be in the array. A reduce
function can take it from there and add up a total.
Conclusion
Asynchronous operations in JavaScript have evolved.
The humble callback solves simple use cases but as complexity grows it falls flat. A Promise builds on top of callbacks via an object that wraps around a callback. This makes complex async code easier to think about. To make the code readable, async/await builds on top of Promises to make it look like synchronous code. If the code can run in parallel, both a Promise and async/await can work together.
In JavaScript, there is no false dichotomy. Features build on top of each other to exploit current expertise. Mastering callbacks puts you on the path to master Promises and async/await.
Originally published on the Jscrambler Blog by Camilo Reyes.