The Structure of Asynchronous Javascript Code

David Booth

Asynchronous programming is hard. It is a form of abstract reasoning that challenges human brains. If you don't believe me, at least believe David Flanagan:
Promises seems simple at first, and the basic use case for Promises is, in fact, straightforward and simple. But they can become surprisingly confusing for anything beyond [...]

This can be one of the trickiest parts of Javascript to understand, and you may need to read this section more than once.
- David Flanagan, Javascript: The Definitive Guide
"You may need to read this section more than once" - or in my case, read the section more than once, struggle with production code, read the section some more, and then write a long-winded article about asynchronous programming in Javascript.

Did I say long-winded? I meant "comprehensive but refreshingly concise". So let's get right into it.

Part 1 of this article is about the different kinds of important asynchronous programming patterns in modern Javascript. Part 2 is about popular libraries for asynchronous code, which not only explains how these libraries work but hopefully gives a lot of insight into the different ways that people like to write async code, and they way it has evolved over time. Part 3 will be about some technical details and "gotcha"s that I have run into while writing async code, and Part 4 will be a list of examples of async code "in the wild" that I thought were instructive - all taken from a recent refactoring project I did.

Quick Links
      

Part 1: Asynchronous Programming Patterns

Let's Be Side-By-Side

I'm going to jump right into an example: reading a file into your program.

In Python it would work like this:(Ex: 1)

1 print("Let's read a file")
2 f = open("file.txt", "r")
3 fileText = f.read()
4 print("The file has been read.")


Pretty straightforward. This is a single-threaded program, everything uses the same call stack, and everything happens in the same sequence of the lines of code. The file is read synchronously, which means it is in-sync with the rest of the program.
  1. We print a message to the console
  2. We open the file
  3. We read the file
  4. We print a message to the console.

Here's how we might do it in Node.js:(Ex: 2)

1 fs = require("fs")
2 console.log("Let's read a file")
3 fs.readFile(path, encoding, callback)
4 console.log("We did it!")
This looks pretty similar to what we did in Python. The function calls are slightly different and we had to import a required module, but the program is fundamentally the same right?

Wrong.

Like the Python code, this Javascript code is still single-threaded, uses one call stack, and everything happens in order:
  1. We import the required module
  2. We log a message to the console
  3. We make a request to the node.js "fs.readFile" api
  4. We log a message to the console.

See the difference? The readFile function doesn't actually read the file. It tells the environment (in this case it is Node, but it could be a browser or anything else) to read the file. The actual reading of the file is done "asynchronously", that is, it happens out of sync with the rest of the code. We don't actually know when the file will finish being read. So how do we work with it?

Will you call me back?

You will notice that fs.readFile() takes a "callback" as the third argument. This argument expects a function, and the node api will run that function once it has completed. The structure may vary, but callbacks in Node are usually passed two arguments. The first will be an error if an error was thrown, or null if no error was thrown. The second will be the return data, if present.

So for example, if we wanted to print out the contents of the file to the console, our code would look like this:(Ex: 3)

1     fs = require("fs")
2     console.log("Let's read a file")
3     fs.readFile(path, encoding, (err, data) => {
4         if (err) {
5              console.error(err)
6              return
7         }
8         console.log(data)
9     })
10    console.log("We did it!")
            
This is pretty much the exact example from the official Node website, and while it serves to illustrate the concept of using callbacks to handle async code, it really doesn't help us understand what we would do if we want to keep the data around and use it later, rather than just immediately doing something with it and moving on. In real life we often want to save the file to a variable and use it later in the program. If we want to be able to write code that acts synchronously like the Python example, we are still out of luck1 - this is what the current code will output:

Let's read a file
We did it!
[file text]

It prints "out-of-order" because the call stack has to clear before the node api will put the callback onto the stack, so no matter when the file finishes being read, it will always run the callback after console.log("We did it!"). The implication of this is that when the console says "We did it!", we will not, in fact, have done it, and we may never do it if an error is thrown during the file read.
1Actually, if you want to write a Node program which reads a file synchronously, just use fs.readFileSync() instead of fs.readFile(), and save yourself the trouble of reading this article.

If you make me a promise...

If we want to get the return value of the callback so that we can use it later, we can wrap the callback in a promise:(Ex: 4)

1     fs = require("fs")
2     console.log("Let's read a file")
3     let file = new Promise((resolve, reject) => {
4         fs.readFile(path, encoding, (err, data) => {
5             if (err) return reject(err)
6             resolve(data)
7         })
8     })
9     console.log("We did it!")
            
Now we have a variable called file, which is a promise object, which represents the result of the asynchronous computation.

Again: a promise represents the result of an asynchronous computation.

A promise represents the result of an asynchronous computation.

Okay okay but what even is the result of an asynchronous computation? After all, isn't it initially undefined because the computation hasn't been performed?

This isn't the easiest question to answer, but it isn't the hardest either. There are fives states a promise can be in:
Initially, the Promise is Pending, and once the asynchronous computation has been completed, the Promise is Settled. A settled promise will not change its value any more. Settled promises can be either Fulfilled (the computation was successful), or Rejected (the computation threw an error).

So in Example 4 above, the asynchronous computation is fs.readFile() (and the corresponding callback). Initially, the Promise is Pending. At some point, Node will finish with readFile() and run the callback. If the callback receives an error, it rejects the promise by calling reject(), and otherwise it calls resolve().

Immediately after the callback rejects or resolves, the Promise will be either Rejected or Resolved.

Not so bad, right?

Hey, you just said a Settled Promise would be Rejected or Fulfilled, not Rejected or Resolved, so what the heck is going on here? Why doesn't the callback call a function named Fulfil()"

- You, just now
Okay fine it's not quite that simple. Remember what we just said about Settled Promises? For a value to be "Settled", it should be in a state that won't change anymore. However, the argument we pass in to resolve() could itself be an unsettled Promise. In this case, the Promise has Resolved, but to an unsettled value. It is a subtle but meaningful distinction.

A Resolved Promise will Fulfil or Reject as soon as the value it has resolved to is settled. For a simple example like our readFile() Promise, this will happen immediately and we will never have to worry about these subtleties.

... you better keep your promise!

Now we know how to save the result of an async computation to a variable in our program, but how do we use it? After all, we hardly want to start working with the contents of a file if the "file data" is actually just a Pending Promise and not actually the data at all.

There are two ways to use Promises.

The first is to use callbacks with them. Promises have methods called .then() and .catch(), which let you define callbacks which will be run when the promise Fulfils or Rejects. You can chain multiple .then() calls together, to pass the result of one to the next. This is a great way of writing asynchronous code, it was the original goal of Promises when they were first introduced, its very useful and solves a lot of the problems with purely callback-based code, and I'm not going to talk about it at all. This whole article assumes that the reader already has some familiarity with async Javascript, and there are plenty of good descriptions already out there.

After promises were introduced, some smart people figured that it would be great if you could tell your program that you want to wait for the Promise to Settle before it moves to the next line of code, which led to the development of a second use pattern for Promises.

Consider this pseudo-example:(Ex: 5)

1     fs = require("fs")
2     console.log("Let's read a file")
3     WAIT let file = new Promise((resolve, reject) => {
4         fs.readFile(path, encoding, (err, data) => {
5             if (err) return reject(err)
6             console.log("The file was read:")
7             resolve(data)
8         })
9     })
10    console.log(file)
11    console.log("We did it!")
The idea is that this code would basically run synchronously, it would actually output the console.log() statements in the order that they appear in the program, and at the point when console.log(file) runs, "file" is guaranteed to be Settled. It still wouldn't be synchronous code, strictly speaking, but we could write it and reason about it as though it is, which makes a lot of things easier.

Okay so if you're like me when I first learned this, at this point you're going saying something like this:
What?? I thought the whole point of asynchronous code was that it DOESN'T pause the program execution? Why would we go through all the trouble of writing async code and then go to more trouble to make it act like its synchronous?
- David Booth, 2021
And you're right! You shouldn't do that. What gets missed here is the fact that this whole thing is still going to be async. Consider this example:(Ex: 6)

You have a program you're writing and you want to be able to call a function to find out what the current time is. You also want to be able to pass in a string describing the format you want the time to be returned in.

I say "great, I'll write a module for you that will expose a function that does that, named whatTimeIsItRightNow(format)"

So I write the module: it parses the input, queries WhatTimeIsIt.com to find out the current time, then formats the time the way you want and returns the formatted time as a string.

Inside my module, when I query WhatTimeIsIt.com, that query will be an asynchronous operation, and I need to be able to wait for the response before I can format it and pass it back. That's where I would use the kind of construction shown in Example 5.

Makes sense? Probably not, because the thing that we haven't talked about is the fact that I cannot return a string from this function. Because I am waiting for an async operation, my whole function is going to be asynchronous, and I have to return a Promise.

This means that whatTimeIsItRightNow() is going to return a promise, and the program that imports and runs it is still going to have to deal with it somehow.

Nonetheless, the ability to wait for a Promise was useful, because no matter what else we do, we still want to format the date string, and we still have to wait for the query to resolve before we do that, and being able to wait for the promise made it easy for us to do those things. And of course, waiting for the query didn't block the call stack, because the whole function is async.

I promise I'll wait for you

How do we wait for promises to resolve? If we are writing code in ES2017 or later, this is trivial to do with the async/await pattern. With async await, Example 6 would be implemented like this:(Ex: 7)

1     const whatTimeIsItRightNow = async function (format) {
2         // Parse format argument
3         let format = parseFormat(format)
4       
5         // Get time from server
6         let time = await queryWhatTimeIsIt()
7
8         // Format time
9         let formattedTime = formatTime(time, format)
10
11        // Return
12        return formattedTime
13    }    
That's it. Because the function is declared as an async function:
  1. It will return a Promise.
  2. We are allowed to use the "await" keyword inside the function.

Part 2: Popular Practical Promise Paradigms and Patterns


In Part 1 we discussed how asynchronous code runs in the Javascript environment, and what callbacks and promises are. However, there are two important things to consider:

Here in Part 2 we will look at some of the async libraries that exist, to understand different async patterns and stuff.

Modern Promise Functionality in JS

Let's start by just looking at some simple ideas for functionality:
  1. I have a block of code that I want to run once the promise settles, whether it resolved or rejected.
  2. I have multiple async operations to perform, and I want all of them to complete before I call .then() or before moving on from await.
  3. I have multiple async operations I'm going to perform, but I only need the result of the first one that finishes

1:
- Use .finally() at the end of the Promise chain. The function you pass in does not take any arguments.

2:
- If you want it to "fast-fail" (reject the whole thing if ANY of the Promises reject) use Promise.all() to wrap an iterable of the Promises.
- If you want to get an array of the results - resolved or rejected - use Promise.allSettled() to wrap the Promises.

3:
- If you want the result of the first Promise that fulfils successfully, and to only get an error if ALL fail, wrap an iterable of the Promises in Promise.any().
- If you want the result of the first Promise to settle, regardless of whether if fulfils or rejects, wrap with Promise.race()

Before Modern Features - Thunks, Promises, and Yield with co

co is the first "legacy" library we will look at (the last update was Oct. 2016). I consider co to be the standard for async/await before async/await was a thing. co is well-written and very popular - as I write this at the end of 2021, it still has almost 16 million weekly downloads! It uses co-routines with generators to expose an API which is super simple and almost identical to async/await, with only two functions: co(), and co.wrap().

Let's see what some of the examples from Part 1 would look like if written with co:

Example 5 with co:

1     fs = require("fs")
2     co( function* () {
3         console.log("Let's read a file")
4         yield let file = new Promise((resolve, reject) => {
5             fs.readFile(path, encoding, (err, data) => {
6                 if (err) return reject(err)
7                 console.log("The file was read:")
8                 resolve(data)
9             })
10        })
11        console.log(file)
12        console.log("We did it!")
13    })

Example 7 with co:

1     const whatTimeIsItRightNow = co.wrap( function* (format) {
2         // Parse format argument
3         let format = parseFormat(format)
4       
5         // Get time from server
6         let time = yield queryWhatTimeIsIt()
7
8         // Format time
9         let formattedTime = formatTime(time, format)
10
11        // Return
12        return formattedTime
13    })    
In essence, "yield" is used in place of "await", and the function declaration is slightly different. co.wrap() is used to wrap a function2 into an async function, and co() just directly runs a block of code as though it were async (like an IIFE). Both functions return a promise and are "then-able", i.e. the following are valid:

co(function* () { [...] }).then().catch()

const asyncFunc = co.wrap(function* () { [...]})
asyncFunc().then().catch()


Pretty straightforward, right?
Hey, co was first released in 2013, but Promises weren't even part of the ECMAScript spec until 2015. Also, I just looked at the co documentation and it lists six different yieldable types, not just Promises! What aren't you telling us?"

- You, again
Okay okay fine. Here's the deal. Co can also handle yield-ing a generator or generator function (basically nesting the operation), it can also handle arrays and objects so that you can do parallel processing, and - this is the big one - it can handle "thunks".

Before Promises took over, async code was all about functions, and passing functions around. Sure enough, this is how co worked as well. You would yield a function which took just one argument, a callback, and co would pass in its own special callback and work its magic:

1    const asyncFunc = (callback) => {
2        try {
3           result = someOperation()
4           callback(null, result)
5        } catch (err) {
6           callback(err)
7        }
8    }
9
10   co( function* () {
11       yield asyncFunc
12   })
            
You can see that the callback is in the classic style - it takes two arguments, the first is the error, and the second is the result.

This is great if you have a function that doesn't take any arguments, but most of the time that isn't the case.

Here is the solution, for a simple (if contrived) example of making an XHR request:

1    const makeRequest = function (url) {
2        function (callback) {
3            xhr(url, callback)
9        }
10   }
11
12   co( function* () {
13       yield makeRequest("http://www.example.org/example.txt")
14   })
See how it works? Inside co, yield makeRequest(args) works just fine, because makeRequest(args) evaluates to a function which takes the callback as its only argument. The co() block is still super readable. The only thing we need to understand is the little functional programming pattern we used to define makeRequest.

makeRequest is a function which returns a function. The specific pattern here is defined by two things:
  1. The inner function that gets returned is something that needs to take very specific arguments in a specific order
  2. The arguments of the outer function are used to define the behavior of the inner function
You could say that once we've passed in the arguments to the outer function, we get back a function which has "already thought about it what its going to do". Or, to put it another way, it's already "thunk" about it.

That's right, this pattern is called a "thunk", and that little play on words is the reason why it has that name. I swear I'm not making this up.

Thunks aren't just used for async code with co, they are a broader functional programming pattern that crop up in other places, like in redux.
2: The function has to be a generator function. A generator is like an iterable computation - on each iteration, you pick up where you left off and go to the next yield statement. Co() works by wrapping the generator in some code that manages when the iterations are run. All you need to do to convert a normal function to a generator is add the asterisk.

Don't keep me in suspense! yielding with suspend

The suspend library is not popular (~150 weekly downloads at time of writing), but it was used by the codebase I am responsible for professionally, and it is a little instructive, so I'll talk about suspend.run() which is the feature that we used.

In a simple case, suspend.run() is basically identical to co(). Consider example 5 with suspend.run():

1     fs = require("fs")
2     suspend.run( function* () {
3         console.log("Let's read a file")
4         yield let file = new Promise((resolve, reject) => {
5             fs.readFile(path, encoding, (err, data) => {
6                 if (err) return reject(err)
7                 console.log("The file was read:")
8                 resolve(data)
9             })
10        })
11        console.log(file)
12        console.log("We did it!")
13    })

It's exactly the same as when we used co(). suspend.run() wraps the code and allows us to use yield to wait for the Promise to Settle.

Lets take a step back for a second to look at what's going on here. fs.readFile() is a callback-based async function, so we wrapped it in a Promise, so that we could wait for the Promise to Settle and then use that Settled value. If we used async/await, this would be necessary, but Suspend offers another way:

1     fs = require("fs")
2     suspend.run( function* () {
3         console.log("Let's read a file")
4         file = yield fs.readFile(path, encoding, suspend.resume())
5         console.log("The file was read:")
6         console.log(file)
7         console.log("We did it!")
8     })

Instead of wrapping fs.readFile() in a Promise, we simply pass suspend.resume() as the callback. When the computation that yield is operating on is complete and the callback is run, Suspend knows how to resume the function execution and return the computation result.

This is similar to what we did in co(), but suspend lets you manually tell it how to pass the callback around, so you don't have to write thunks.

async functions with ... async

Gotta write this!

Part 3: Boring Details and Spicy Gotchas


Call Stack and Event Loop

What does this code output? You can assume there are no errors thrown during execution.

9    // getUser is a function that queries the database for the currently logged in user
10   // it returns a promise which will resolve to the user if it is successful
11    
12   console.log("search for user")
13    
14   const userTalk = async function () {
15       console.log("Hi there, remember me?")
16       let user = await getUser()
17       console.log("Of course I remember you, " + user.name + "!")
18   }
19    
20   userTalk().catch(err => console.error(err))
21    
22   process.exit()
Let's walk through it:
- On line 12 it outputs "search for user", which obviously works.
- An async function is defined
- On line 20 it calls the function
- Stepping in to the function: on line 15 it outputs "Hi there, remember me?"
- On line 16, wait for the result of getUser()
- On line 17, output another message
- Now that the function is done, continue past line 20, to process.exit() on line 22

So that's all good and fine, except that its wrong. If you've ever read a tutorial or description about async/await that says something like "await makes the code act like its synchronous even though its asynchronous" this is what they are talking about. Even though it LOOKS synchronous, under the hood it still isn't. This is one of those examples where there will be unexpected behavior if you don't know the difference.

Here is the actual execution:

- line 12 outputs a message
- an async function is defined
- line 20 calls the async function
- line 15 outputs a message
- line 16 sends off getUser() to be executed
- await makes the async function exit for now, ready to resume when the result of getUser() comes back
- the call stack, which is currently just main(), continues executing
- line 22 kills the process
---- if line 22 wasn't there ----
- the stack clears, and when getUser() is done, the event loops pulls it back onto the stack
- the async function resumes with the result of getUser()
- line 17 outputs another message

One of the important things to notice here is that if there was no process.exit() on line 22, it would behave exactly like the first execution sequence we listed, and look very much like synchronous code.

Explanation of call stack, promise and callback queues, API land, and event loop.

IIFEs

In Part 2 we looked at the use of the co library. Let's look at a little snippet:

10    const doSomethingAwesome = () => {
...        
18    }
19
20    co( function ()* {
...
26    })    
We don't know what this code does, because it's collapsed, but the structure is clear. Some function is defined and then some async code is run inside a co() block. Technically async/await has no exact replica of this, because co() runs the contained code immediately, but if we want to do the same thing with async/await we can define a function and immediately invoke it. This is called an "immediately invoked function expression" or IIFE.

10    const doSomethingAwesome = () => {
...        
18    }
19
20    (async function () {
...
26    })()
We can chain .then() etc. to the end as well. So what does this code do?

The code above defines a function called doSomethingAwesome and then immediately calls it with an async function as an argument. Immediately invoked function expression!

Wait what?

No I didn't make a mistake. The function which is defined and immediately called in the above code is the function doSomethingAwesome. Remember that in Javascript, semicolons denote breaks between statements, not line breaks. Javascript will usually interpret a line break as a semicolon, but not if it is able to continue the statement using the next line. David Flanagan says "in general, if a statement begins with (, [, /, +, or -, there is a chance it could be interpreted as a continuation of the statement before." You can put a semicolon at the end of line 18, of course, but if the code gets changed later the same problem might appear. A "defensive semicolon" can be used to resolve this:

10    const doSomethingAwesome = () => {
...        
18    }
19
20    ;(async function () {
...
26    })()

Part 4: Examples


Placeholder

Gotta write this!

back