We live in a networked world. One of the big constraints when building modern software is that the data you need often lives on a remote server. It’s not realistic (or even possible) for an application to simply lock up while waiting for a network request to complete. Instead, we need ways for our applications to continue responding while they wait.
To achieve this goal, we need to write code that runs concurrently. While one piece of an application is waiting for a response to a network request, other parts must continue to run. Promises are a really useful tool for writing non-blocking code, and they’re available today in your favorite browser.
Promises make potentially frightening asynchronous code look downright friendly. For example, here’s how a hypothetical
ArticleView in a “blog” app might load an article from a remote server and display it:
Note: This example uses React, but the concept applies to most frontend view systems.
Code like this is beautiful. Much of the complexity of the asynchronous calls just melts away, leaving straightforward, obvious code. However, using promises does not guarantee correct code.
Did you notice the flaw in my example that introduced a subtle race condition?
Here’s a hint: The race condition arises because there is no guarantee that asynchronous operations will finish in the same order they were started.
When the Wheels Fall Off
To illustrate the race condition, let’s say the blog application shows a list of articles on the left, and the content of the selected article on the right, like this:
We start off with the first article selected. Next, the user selects the second article. The app sends a request to load the article content (
this.store.fetchArticle(2)), and the user sees a loading indicator, like this:
Because this is The Internet, the article takes a while to load. After a few seconds, the user gets bored and (re)selects the first article. This article appears almost instantly because it has already been loaded, which puts the app pretty much back where it started:
But then something strange happens: the app finally does receive the content of that second article. Not knowing any better, our
ArticleView dutifully updates its title and body to display this newly-loaded content, which leaves the user seeing this abomination:
The article list (and probably the URL and other UI elements) indicate that the first article is selected, but the user sees the content of the second article.
This is a huge problem! It’s made even worse because you are unlikely to see this happen in development. On your local machine (or local network, etc), load times are faster and more predictable. And, when working on your code, you probably won’t get “bored” waiting for requests to complete.
Putting the Wheels Back On
The first step towards fixing the problem is to get a clear, simple idea of what is wrong. The basic recipe for the race condition we encountered is:
- An asynchronous operation begins while the app is in State A (“article 2 selected”).
- The app changes to State B (“article 1 selected”).
- The operation completes, and the code proceeds as though the app is still in State A.
Having identified the problem, we can now work out a solution. Like with most bugs, there are many possibilities. An ideal solution would make it impossible to create this bug in the first place. For instance, many router libraries resolve promises as part of the routing, which eliminates this entire class of bug. If you have a tool like that at your disposal, use it.
In this situation, though, we need to manage these promises ourselves. It probably won’t be practical to implement something that makes creating the bug impossible here, so we’ll have to settle for something that makes neutralizing the race condition easy and obvious.
My favorite “easy and obvious” solution works like this:
- When an asynchronous operation starts, record the relevant app state.
- When it completes, verify that the app is still in the same state.
For my example, this looks something like:
The reason I like this solution is that all of the code related to recording and verifying the state is in one place, right next to the async code.
This issue is not unique to promise-based code. You could have the same problem with code that used Node-style callbacks, for example. Promise-based code has a tendency to look so harmless, though, that it can be easy to miss errors like this. And even though I’m excited to use async functions and the await keyword, I’m a little worried that they could make it even easier to overlook these errors (here’s an example).
I’ve made up the example in this article, but it’s not a contrived example. It’s based on real code that I’ve seen in real production apps.
Asynchronous code is one of the most difficult things for developers to understand. The execution order possibilities grow exponentially with the number of asynchronous operations, which quickly leads to code that is impossibly complex.
If you have the option, take advantage of platform- or framework-level abstractions to manage this added complexity. If you don’t have that option, it’s best to treat asynchronous operations as a hard boundary. Act as though everything you knew about the world may have changed by the time your code resumes, because it might have.