ES6 callback triangles. Wait, what?

ES6 callback triangles. Wait, what?

Writing code that deals with asynchronous requests has come a long way since the “callback hell” days of the previous decade. Promises and then async/await have made our lives easier. However, even with all our ES6 power, there’s quite a bit to consider when you’re writing more complex code chains that involve several asynchronous calls with regards to their performance impact. This post will explore how we at Data4Life came up with a mixture of Promises and async/await calls to maximize performance.

Wait for it… wait for it… wait for it…

Asynchronous calls are not only about requests to a server. Many native cryptographic operations we rely on, file or BLOB operations and even interactions with indexedDB, are not actually synchronous. Additionally, when it comes to requests to our servers, the nature of our encryption means that we can’t rely on technologies such as graphQL to fetch the exact set of resources down to their last FHIR property. Instead, we have to make a few simple calls to fetch various resources that we then filter and map client-side.

What you might end up is code that looks like this, loosely adapted from our app’s Stencil-based Coronavirus symptom tracker feature:

const userAccountInformation = await getUserDetails();
const fhirQuestionnaireResources = await D4LSDK.getResources({resourceType: QuestionnaireResponse});

const userAccountCreationDate = userAccountInformation.createdAt;
const latestSymptomQuestionnaire = fhirQuestionnaireResources.find(…);

const surveyTypeForSymptomQuestionnaire = await getSurveyType(latestSymptomQuestionnaire);

So, is this a smooth, performant example of 2020 JavaScript in all its glory? Well, not so fast my asynchronous friend!

It’s easy to read – something we value dearly when it comes to our code – but every single one of these awaits makes the browser, you guessed it, wait for its execution. So we’re actually preventing another call that in no way depends on the user details. But we need all this information at the end to display our timeline.

The solution? Parallel requests!

Promise everything

If we wrap the calls in a Promise.all array that we await, the code looks like this:

const [userAccountInformation, fhirQuestionnaireResources] = await Promise.all([
	getUserDetails(),
	D4LSDK.getResources({resourceType: QuestionnaireResponse})
	]);

const userAccountCreationDate = userAccountInformation.createdAt;
const latestSymptomQuestionnaire = fhirQuestionnaireResources.find(…);

const surveyTypeForSymptomQuestionnaire = await getSurveyType(latestSymptomQuestionnaire);

The first 2 requests now run in parallel and will resolve as soon as both are done. While this does indeed unblock independent requests, it still isn’t ideal. Fundamentally, any subsequent operations below the Promise.all will only be evaluated once the slowest original call is resolved. This is especially problematic if subsequent operations require further trips to a backend, or if one them is disproportionally slower. So what do we do?

Parallel promise threads

In the words of the great poet Nick Kamen: “I promised I’d wait for you.” Bring in your 2008 triangle code, finally justify that ultrawide gaming monitor purchase, and add nested await/Promise logic base to the code:

const [userAccountInformation, surveyTypeForSymptomQuestionnaire] = await Promise.all([
	getUserDetails(),
	D4LSDK.getResources({resourceType: QuestionnaireResponse}).then(fhirQuestionnaireResources => {
		const latestSymptomQuestionnaire = fhirQuestionnaireResources.find(…);
		return await getSurveyType(latestSymptomQuestionnaire);
		});
	]);

const userAccountCreationDate = userAccountInformation.createdAt;

This combines the best of both worlds: we parallelize operations that can be run in parallel while not waiting for a request’s completion where we don’t need to. In our tests, not only did we see the requests being processed in parallel in the developer tools network tab, we also measured a more than 20% decrease in loading time.

Of course, production code will likely be a little more complex. We might need to access the requested fhirQuestionnaireResources together with userAccount details later, or return multiple variables, using all ES6 destructuring goodies. If the functions within the promises become too complex, we will consider putting them in their own methods – something which is not only great for the code legibility but also makes it painstakingly obvious if functions are not pure.

Finally: other approaches

There are other approaches for parallelizing asynchronous workloads that go beyond a few requests needed for a stencil render function. If something like long-running image operations were in place and it’s fine to rely on a subsequent population of all our asynchronous results, web workers might be an option. You might also gracefully display what is available and use tools like React suspense or subcomponent based loading states to improve the perceived performance of your application and communicate clearly to the user that progress is happening.

Final thought: there’s no good synonym for asynchronous.

Share using social media