How to refactor a Promise chain to async functions
Convert a series of then() functions to async/await without losing function scoping
Refactoring to async/await
Javascript Promises are kind of a legacy things now that async/await has widespread support, but they are still the engine that drives everything asynchronous.
But constructing one and using the then
functions for chaining is increasingly rare. This prompts refactoring from a Promise-based chain to an
async/await construct.
For example, this async code uses chaining:
doSomething()
.then(doSomethingElse)
.then(finishWithSomething);
The same functionality, rewritten to a series of await
s:
const sth = await doSomething();
const sthElse = await doSomethingElse(sth);
const fin = await finishWithSomething(sthElse);
This yields not only shorter but also more familiar code as it looks like a synchronous one. All the complexities are handled by the async/await constructs.
But when I convert a then
-based structure to async/await, I always have a feeling that the original code was better at scoping the variables inside the
steps, while the async/await version leaks them.
Promise chain
Let's see a hypothetical multi-step proccessing that gets and resizes the avatar for a user id:
const width = 200;
const res = (result) => console.log(`Sending ${result}`);
Promise.resolve(15)
.then((id) => {
// get user
return `[user object ${id}]`;
}).then((user) => {
// get avatar image
return `[image blob for ${user}]`;
}).then((image) => {
// resize image
return `[${image} resized to width:${width}]`;
}).then((resizedImage) => {
// send the resized image
res(resizedImage);
});
// Sending [[image blob for [user object 15]] resized to width:200]
Each step can use asynchronous operations, like connecting to a database or using a remote API.
By using then
s, the variables declared inside a function are local to that function and are not accessible from other steps. This makes the code easier
to understand as the number of variables in scope is limited.
For example, if getting an image uses a fetch
and stores the result in a value, it won't be visible in the next step:
...
.then((user) => {
const imageRequest = ...;
// ...
}).then((image) => {
// imageRequest is not accessible
})
This is true for the arguments also:
...
.then((user) => {
// ...
}).then((image) => {
// user is not accessible
})
Naive conversion to async/await
Converting the above code to async/await is trivial, just add await
s before the steps and assign the results to a variable:
const width = 200;
const res = (result) => console.log(`Sending ${result}`);
const id = 15;
// get user
const user = await `[user object ${id}]`;
// get avatar image
const image = await `[image blob for ${user}]`;
// resize image
const resizedImage = await `[${image} resized to width:${width}]`;
// send the resized image
res(resizedImage);
The code is shorter and doesn't look asynchronous at all.
But now every variable is in scope for the whole function. If one step needs to store something, nothing prevents the next step to access it. Also, every result for a previous step is accessible:
// get avatar image
const imageRequest = ...;
const image = await `[image blob for ${user}]`;
// resize image
// imageRequest is in scope
// user is also in scope
const resizedImage = await `[${image} resized to width:${width}]`;
The original structure of an async pipeline with clearly defined steps and boundaries is lost in the conversion.
Async IIFEs
Since one of the bigger problems is variable scoping, it can be solved by reintroducing a function for each step. This keeps the variables declared inside from leaking to the next step.
The structure for an async IIFE:
const result = await (async () => {
// ...
})();
The example above using this approach would look like this:
const width = 200;
const res = (result) => console.log(`Sending ${result}`);
const id = 15;
// get user
const user = await (async () => {
return await `[user object ${id}]`;
})();
// get avatar image
const image = await (async () => {
return await `[image blob for ${user}]`;
})();
// resize image
const resizedImage = await (async () => {
return await `[${image} resized to width:${width}]`;
})();
// send the resized image
res(resizedImage);
This takes care of variable scoping, but the preceding results are still available.
But the bigger problem with this approach is that it looks ugly. The synchronous-looking structure is still there, but with so much boilerplate it doesn't look familiar at all.
Async reduce
A different approach is to use a structure similar to functional collection pipelines. It requires
separate functions for each step, then an async reduce
to call each of them in order. This structure not only keeps everything separated as every function
has access only to its arguments, but also promotes code reuse.
Processing steps
The first step is to move the steps to separate async functions:
const getUser = async (id) => {
// get user
return `[user object ${id}]`;
};
const getImage = async (user) => {
// get avatar image
return `[image blob for ${user}]`;
};
// see below
// const resizeImage = ...
const sendImage = async (image) => {
// send the resized image
console.log(`Sending ${image}`);
};
When a function needs not just the previous result but also some other parameters, use a higher-order function that gets these extra parameters first and then the previous result:
const resizeImage = (width) => async (image) => {
// resize image
return `[${image} resized to width:${width}]`;
};
Note that all of the above functions conform to the structure of either async (prevResult) => ...nextResult
or (parameters) => async (prevResult) => ...nextResult
. And the latter can be converted to the former by calling it with the parameters.
Async reduce
structure
With functions that get the previous result to produce a Promise with the next one, a reduce
can call them while also handling await
-ing the results:
[
getUser,
getImage,
resizeImage(200),
sendImage,
].reduce(async (memo, fn) => fn(await memo), 15);
In this example, the functions define the steps and the value flows through them. The 15
is the initial value (userId
in the previous examples), and the
result of the reduce
is the result of the last function.
This structure preserves the clearly defined steps of the original Promise chain-based implementation while also takes advantage of the async functions.
Conclusion
While Promises are not deprecated at all, using async/await instead of them yields code that is easier to understand. But rewriting everything
that is using Promises by replacing then
s with await
s usually yields a structure that is less maintainable in the long run. Using an async reduce
helps retain the original structure.