A Question from Years Ago

I was lying awake in bed this morning and I just came up with a great answer to a simple interview question I floundered on 4 years ago.

You may have this experience as well; lying awake, thinking of all the things you could have done differently, better come backs or punchlines. I feel that way sometimes. I also have little questions that keep coming back to me. I often feel that there are ideas on the horizon of my understanding and they bother me until I've fully resolved them. These are little tickles of curiosity, and I know that if I keep quietly, persistently looking for an answer I'll unlock a satisfying insight.

This particular curiosity is on a smaller scale, but I think it's worth putting finger to keyboard because I see a lot of bad explanations online.

4 years ago I had one of my few "real" technical interviews. I didn't get the job, but probably not because of this question alone. The interview was to create a simple node.js server and the interviewer asked me in an offhand way if I knew the difference between the asynchronous function syntax await and .then(). We use asynchronous functions when we need to go get some data and then do stuff with that data, so you're often forced into await -ing that data so that you can then process and return it. Since this is really the main use case, the distinction can seem pretty meaningless, and .then() may seem like an atavism from javascript's prior preference for callbacks.

Many Reddit posts and online articles will say that the main difference has to do with readability and that people find await easier to understand. My personal preference is more of a mixed bag, using await for most cases mixed with .catch() but sometimes processing data using .then() and occasionally reaching for .finally(). In the interview my answer was an unsatisfying "I don't know" which was foolish because I could have said many more useful things that pointed to the correct answer.

Since that interview I've thought of a few good answers. I think "readability" is one of the weakest, since readability is (somewhat) subjective and both syntaxes actually do different things.

The biggest and most obvious difference is that await pauses the control flow while the asynchronous function works, whereas .then().catch().finally() doesn't. This was the best answer I could've, and should've, given at the time. That answer is probably good enough for a junior (and at some places a senior) developer position, but it's not a fully satisfying answer to me. A full answer should include when do you want to use one or the other? And how do each of these shape your code?

Since I began trying to understand Promises, there has always been an important clue to understanding the difference between these syntaxes staring at me while typing code:

'await' expressions are only allowed within async functions and at the top levels of modules ts(1308)
handler.ts(27, 17): Did you mean to mark this function as 'async'?

What's going on here is called "function coloring" which means once you use await that function is "marked" or "colored" and distinct from plain vanilla functions. As a result, you have to mark the enclosing function as async and any callers will be returned a Promise<T> instead of just T. await is like a virus that spreads through your code base, .then() doesn't have this effect.

This is a pretty cute jargon-y distinction, but it's also important to understand. I think the main reason why it's often glossed over is because it's not meaningful in the most common case. Since the main use case for async functions is to go and get some data then process and return that data, generally you will be await -ing the result. That doesn't mean .then() is useless though! And understanding how you can use .then() to keep chugging on the main thread while handing data when it becomes available can be super useful.

Of course there are the straight ahead side effect use cases like logging or firing notifications. And there will always be some frustrating cases where you don't have the time, authority or attention span to convert the caller context into an async function. But there are a number of more interesting "async native" patterns that open a powerful world where you may not need to mark functions with async at all. These patterns are built around the idea of returning and handling Promises.

Ultimately this allows for more performant code that can delegate work to other threads, and better user experiences that can show data as soon as its available without waiting on other data that might be slower to load. But something else happens here if you can handle asynchronous values: your functions become synchronous. You can always operate on Promises in a synchronous function using .then(). The technical requirements are that you need to be able to handle both pending and resolved states in your presentation layer (aka frontend), and you need to be able to pass Promises from your data layer (aka backend) to your presentation layer, which often means over the network. Luckily smart people have worked hard to figure out how to do this.

I'd like to explore two representative use cases that really demonstrate the power of `.then()`. The first will keep our code synchronous while waiting on data and performing async side effects. The second will await some data, but not other data to provide a snappy server rendered interface that is dependent on some (potentially slow) third party API calls.

Caching in-flight requests

// Pseudo Typescript
import { authProvider } from './third-party/auth'
import { auditLog, webhooks, analytics } from './services'
// Side effects—all synchronous functions that return void
// Each attaches a .then() to the shared Promise
function recordAccess(userPromise: Promise<User>, path: string) {
userPromise.then(user => auditLog.write({ userId: user.id, path }))
}
function trackSignOn(userPromise: Promise<User>) {
userPromise.then(user => analytics.track('sign_on', { userId: user.id }))
}
// No async, no await!
// One auth request, multiple consumers
function authMiddleware(req: Request, res: Response, next: NextFunction) {
// simple request scoped cache
req.cache = req.cache || new Map()
function getUser(token: string) {
if (!req.cache.has('user')) {
req.cache.set('user', authProvider.validateToken(token))
}
return req.cache.get('user')
}
const userPromise = getUser(req.headers.authorization)
// Fire side effects immediately
// they share the same Promise
recordAccess(userPromise, req.path)
trackSignOn(userPromise)
// Only here do we need the resolved value
userPromise
.then(user => {
req.user = user
next()
})
.catch(() => res.status(401).send('Unauthorized'))
}

This simple example illustrates the basis of more complex, and much appreciated, libraries like SWR and React-Query. Different callers will get a pending promise or cached value regardless of when or where they want to fetch the async data.

Streaming Parallel Fetches

// Pseudo Typescript
import { use, useMemo, Suspense } from 'react'
import { fetchAllAccounts } from './our-backend/accounts'
import { paymentsService } from './third-party/paymentsService'
import { isDefined } from './utils/isDefined'
import { DataTable } from './components/DataTable'
import type { Account } from './types/accounts'
// I'm thinking in react-router framework mode patterns here, which can do this stuff well
export async function loader(){
// Wait, why is he awaiting here? 🤔
// Look at the component and think about it
// I may write more on this pattern in a future post titled "Thinking in Columns not Rows"
const accounts = await fetchAllAccounts()
// no await!
const balances = accounts.map(a => [a.id, paymentsService.fetchAccountBalance(a.id)])
return {
accounts,
balances
}
}
const Accounts = ({
accounts,
balances
}: {
accounts: Account[],
balances: [string, Promise<number>][]
) => {
const balancesMap = useMemo(() => {
return Object.fromEntries(balances)
},[balances])
return(
<DataTable>
{accounts.map(a => (
<div key={a.id}>
{a.name}
{
isDefined(balancesMap[a.id]) &&
<Suspense fallback='--'>
<Balance balancePromise={balancesMap[a.id]} />
</Suspense>
}
</div>
))}
</DataTable>
)
}
const Balance = ({ balancePromise }: { balancePromise: Promise<number> }) => {
const balance = use(balancePromise)
return <span>{balance}</span>
}

This is a really powerful example that illustrates how we can get the best of all worlds where we can get a snappy user interface rendered on the server with potentially slow data arriving after Promises have settled. This requires a serialization format that supports Promises and a long lived connection that can deliver the data after the Promise is resolved.

If I could go back to that interview question, I would say simply that await pauses execution but .then() allows the main thread to keep working while providing a way to react to the eventual result of the async operation. The more prevalent "readability" explanation is mostly true because await makes your asynchronous function feel synchronous since the result is available on the next line. Of course there's no such thing as a free lunch and the effect is that the enclosing function is now also asynchronous. It's been infected by the async virus. While more complicated to reason about, involving more levels of indentation and more parens and braces, .then() is a more powerful async pattern that can paradoxically keep your main thread synchronous.

There's a fine line between dwelling on these little embarrassing moments and learning from my mistakes. In this case keeping a little background thread of curiosity running over the last few years has led me to put together some really satisfying thoughts about a subtle distinction that seems often poorly explained.