How to refactor a Promise chain to async functions

Convert a series of then() functions to async/await without losing function scoping

Author's image
Tamás Sallai
6 mins

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 awaits:

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 thens, 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 awaits 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 thens with awaits usually yields a structure that is less maintainable in the long run. Using an async reduce helps retain the original structure.

April 28, 2020

Free PDF guide

Sign up to our newsletter and download the "Foreign key constraints in DynamoDB" guide.


In this article