Even with async/await, raw promises are still key to writing optimal concurrent javascript
With es2017, async/await is just around the corner. In a previous article I recommended getting fully to grips with promises, since they’re the foundation that async/await is built on. Understanding promises helps understand the concepts at the foundation of async/await, and help you write better async functions.
But even if you’re already on the async bandwagon (which is one I personally love), and you fully understand promises, there are still some very compelling reasons to continue using them alongside async functions. Async/await absolutely won’t free you entirely from having to break them out every so often. Why? Simple:
- You’re still probably going to need to write code to ship to the browser
- Writing truly concurrent code is occasionally not possible, or easy, with pure async/await.
Writing code for the browser? Isn’t that a solved problem with Babel?
So, obviously unless you’re writing purely for the server-side in node, you’re going to have to consider running your javascript in the browser. Babel provides a really great way of compiling down ES2015+ down to javascript that can be run in older browsers, and using Facebook’s excellent Regenerator, Babel will even compile down async/await code.
Problem solved, then? Well, not quite.
The sticking point is, the resulting code is not necessarily something you’d want to ship in your client side bundles. For example, take this simple async function which maps over an array serially, with an asynchronous mapper function:
async function serialAsyncMap(collection, fn) {
let result = [];
for (let item of collection) {
result.push(await fn(item));
}
return result;
}
This is compiled by Babel/Regenerator to the following 56 line function:
var serialAsyncMap = function () {
var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee(collection, fn) {
var result, _iterator, _isArray, _i, _ref2, item;return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
result = [];
_iterator = collection, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();
That’s just the result of transpiling a seven line function. And by the way — that’s before we even include regeneratorRuntime
or _asyncToGenerator
in our bundle. So while Regenerator is a fantastic technical feat, it doesn’t exactly produce the most streamlined code to ship to the browser — nor, I suspect, the most optimal or performant code. It’s also pretty difficult to read, comprehend, and debug.
Anyway, consider if we’d written the same function using raw promises:
function serialAsyncMap(collection, fn) {
let results = [];
let promise = Promise.resolve();
for (let item of collection) {
promise = promise.then(() => {
return fn(item).then(result => {
results.push(result);
});
});
} return promise.then(() => {
return results;
});
}
Or the slightly more terse version:
function serialAsyncMap(collection, fn) {
let results = [];
let promise = Promise.resolve();
for (let item of collection) {
promise = promise.then(() => fn(item))
.then(result => results.push(result));
}
return promise.then(() => results);
}
There are a few more mental hurdles here than the pure async/await version, to be sure — but the raw promise version is significantly more streamlined, easier to read, and easier to debug than the code Regenerator spits out. The debugging aspect is especially relevant for environments where source-map support is less strong (which are usually the environments where the most debugging is necessary, like old versions of IE).
Is there an alternative?
There are a few alternative async/await transpilers to Regenerator, which take async code and attempt to convert to more traditional .then
and .catch
notation. In my experience, these transpilers work pretty well for simple functions, which await
then return
, perhaps with a try/catch
block at most. But the more complex the original async functions get (with any conditional await
statements or loops) the more spaghetti-like the generated code ends up.
For me, at least, that’s not good enough; if I can’t easily look at pre-transpiled code and imagine what the transpiled result is going to look like, the chances of me being able to easily debug it are also pretty slim.
It’s going to be a long time before async/await gets close to 100% browser support, so don’t hold your breath before you acquaint yourself with writing and composing promises for your client side code.
OK OK, so I still might have to write promises for my client-side, but so long as I’m running node I’m all set to use async/await on my server, right?
Well, yes and no.
Normally you can get away with adding a few async function
and await
statements in your server-side javascript, maybe make a few http requests, and you’re good to go. You can even parallelize async tasks with Promise.all
(even though I think it feels a little out-of-place, and it would be nice to have special syntax for running parallel functions with async/await).
But what happens when you want to write something more complex than “run these asynchronous tasks in series” or “run these asynchronous tasks in parallel”?
Give me an example…
Consider the following. We want to make a pizza.
- We make the dough independently.
- We make the sauce independently.
- We want to be able to taste the sauce, before we decide what kind of cheese is going to work best on the pizza.
So, let’s start with a super-naïve pure async/await solution:
async function makePizza(sauceType = 'red') {
let dough = await makeDough();
let sauce = await makeSauce(sauceType);
let cheese = await grateCheese(sauce.determineCheese());
dough.add(sauce);
dough.add(cheese);
return dough;
}
This has one huge thing in its favor: it’s super simple, and super easy to read and understand. First we make the dough, then we make the sauce, then we grate the cheese. Easy!
But that isn’t exactly optimal. We’re doing things one-by-one, when really we should be allowing the javascript engine to run these tasks concurrently. So instead of:
|-------- dough --------> |-------- sauce --------> |-- cheese -->
We want something more like:
|-------- dough -------->
|-------- sauce --------> |-- cheese -->
That way, the tasks get done a lot quicker. So let’s take another stab at it:
async function makePizza(sauceType = 'red') {
let [ dough, sauce ] =
await Promise.all([ makeDough(), makeSauce(sauceType) ]); let cheese = await grateCheese(sauce.determineCheese());
dough.add(sauce);
dough.add(cheese);
return dough;
}
OK, so our code is looking a bit more funky with that Promise.all
but at least it’s optimal now, right? Well… no. I’m waiting for both the dough and the sauce to be complete before I even start grating the cheese. What if I get done making sauce really quickly? Now my execution looks like this:
|-------- dough -------->
|--- sauce ---> |-- cheese -->
Notice how I’m still waiting for both the dough and the sauce to be made before I even start on the cheese? I only need to finish the sauce before I can start on the cheese, so I’m wasting time here. So let’s go back to the drawing board and try going all-out with promises rather than async/await:
function makePizza(sauceType = 'red') {
let doughPromise = makeDough();
let saucePromise = makeSauce(sauceType);
let cheesePromise = saucePromise.then(sauce => {
return grateCheese(sauce.determineCheese());
});
return Promise.all([ doughPromise, saucePromise, cheesePromise ])
.then(([ dough, sauce, cheese ]) => {
dough.add(sauce);
dough.add(cheese);
return dough;
});
}
This works a lot better . Now each task will be completed as soon as it’s possible, and as soon as all of its dependencies are complete. So the only thing stopping me from grating the cheese is waiting for the sauce to be ready.
|--------- dough --------->
|---- sauce ----> |-- cheese -->
But in doing so, we’ve had to totally opt-out of writing async/await code, and go all-in on promises. Let’s try to reel async/await back in a bit:
async function makePizza(sauceType = 'red') {
let doughPromise = makeDough();
let saucePromise = makeSauce(sauceType);
let sauce = await saucePromise;
let cheese = await grateCheese(sauce.determineCheese());
let dough = await doughPromise;
dough.add(sauce);
dough.add(cheese);
return dough;
}
OK, so now we’re totally optimal, and back in async/await land… but this still feels like a step backwards. We’re having to set up each promise in advance, so there’s some mental overhead there. We’re also relying on those promises being run concurrently, given that the tasks are set up in advance before any of them are awaited. This is not hugely obvious from reading the code, and might be accidentally factored out or broken in future. So this is probably my least favorite implementation.
Let’s try again. We can take one more stab at it:
async function makePizza(sauceType = 'red') {
let prepareDough = memoize(async () => makeDough());
let prepareSauce = memoize(async () => makeSauce(sauceType));
let prepareCheese = memoize(async () => {
return grateCheese((await prepareSauce()).determineCheese());
});
let [ dough, sauce, cheese ] =
await Promise.all([
prepareDough(), prepareSauce(), prepareCheese()
]);
dough.add(sauce);
dough.add(cheese);
return dough;
}
This is my favorite solution. Instead of setting up promises in advance, which are implicitly run concurrently, we’re setting up three memoized tasks (which are guaranteed to only be run one-time each), and invoking them all in Promise.all
to be run concurrently.
We almost avoid referring to promises at all here, with the exception of Promise.all
, even though they’re powering async/await under the hood. This is a pattern I go into in a bit more detail about, in a different article about memoization and concurrency. But in my opinion this leads to the perfect mix of optimal concurrency, and readability/maintainability.
Of course, I’m always willing to be proven wrong, so if you have an implementation of makePizza
that you prefer, let me know!
So we made a pizza really fast, what’s your point?
The point is, knowing how to intermingle promises and async/await is still an absolutely necessary skill if you plan to write fully concurrent code, even in the latest node.js release. Whatever your favorite implementation of makePizza
is, you still have to think about how the promises are chained together and composed to make the function run with as few unnecessary delays as possible.
Async/await will only get you so far by itself, and if you’re not aware of how promises are powering your code, you’ll be stuck without an obvious way to optimize your concurrent tasks.
And on that note…
Don’t be scared to write helpers to abstract out the promise/concurrency logic from your business logic. Once you get your head around how promises work, doing this can remove a lot of spaghetti from your code, and make it far more obvious what your asynchronous app/business-logic functions are intended to do, without crowding them with boilerplate.
Take this function, which checks every ten seconds if a user is logged in, and resolves the promise when it detects that:
function onUserLoggedIn(id) {
return ajax(`user/${id}`).then(user => {
if (user.state === 'logged_in') {
return;
}
return new Promise(resolve => {
return setTimeout(resolve, 10 * 1000));
}).then(() => {
return onUserLoggedIn(id);
})
});
}
This is not the kind of function I’d want to ship — the business logic is very tightly coupled with the promise/delay logic. I have to read and understand the whole mess before I have any idea what the function does.
To improve this, I can split out the async/promise logic into some separate helper functions, and make my business logic a lot cleaner:
function delay(time) {
return new Promise(resolve => {
return setTimeout(resolve, time));
});
}function until(conditionFn, delayTime = 1000) {
return Promise.resolve().then(() => {
return conditionFn();
}).then(result => {
if (!result) {
return delay(delayTime).then(() => {
return until(conditionFn, delayTime);
});
}
});
}
Or the super-terse version of those helpers:
let delay = time =>
new Promise(resolve =>
setTimeout(resolve, time)
);let until = (cond, time) =>
cond().then(result =>
result || delay(time).then(() =>
until(cond, time)
)
);
Then onUserLoggedIn
becomes a lot less tightly coupled with that control-flow logic:
function onUserLoggedIn(id) {
return until(() => {
return ajax(`user/${id}`).then(user => {
return user.state === 'logged_in';
});
}, 10 * 1000);
}
Now I have a little more hope of being able to read and comprehend onUserLoggedIn
in future. So long as I remember the interface for my until
utility function, I don’t have to re-grok its logic every time. I can throw it in a promise-utils
file and forget about how it works, for the most part, and focus on my app logic.
Oh yeah, and we were talking about async/await, right? Well, it’s our lucky day — since async/await and promises are totally interoperable, we’ve just inadvertently created a helper function we can continue to use, even with async functions:
async function onUserLoggedIn(id) {
return await until(async () => {
let user = await ajax(`user/${id}`);
return user.state === 'logged_in';
}, 10 * 1000);
}
So the same rule is true, whether it’s for promise based code or async/await based code — if you find concurrency logic creeping into your async business functions, be sure to think about whether it can be abstracted out a little. Within reason, of course.
Here’s a pretty big collection of existing abstractions that may help a little.
—
So, if you take anything from this article, it’s this: if you’re writing async/await code, not only should you understand how promises work, you should also be prepared to use them to build your async/await code, when necessary. Async/await alone doesn’t give you quite enough power to completely avoid thinking with promises.
Thanks!
— Daniel