Brian Kardell
Betterifying the Web
Follow Me On...
Posted on 08/20/2019
Top Level Await: 2 Minute Standards

My first post in a new effort #StandardsIn2Min to provide short, but useful information about developing standards.

Previously, in order to use await, code needed to be inside a function marked as async. This meant you couldn't use await at the 'top-level' (outside of any function notation). At first, this might seem like a minor annoyance. Just put the code you want to await into an async function() {....}() and call it, right?

Yes, and (mostly) no. While being able to use await at the top-level is generally useful - the real value in it (the problem that it solves) has a lot to do with modules.

Modules are already asynchronous, and have a declarative import and export, and those also expressed at the top-level. The practical implication of this was that if you wanted provide a module which relied on some asynchronus task in order to be useful - for example, connecting to a database - you had really no good options. You might make every method in your own API internally dependent on that promise. But, this is extra complicated. All of your methods need to return promises, whether that makes sense, or not. If errors do occur, they are late, and in the wrong place. Or, you could make your API export that promise somehow. "Somehow" because there are several models for how you can choose to do this. Worse, whichever promise exporting model you chose, users of your module are left with precisely the same problem, creating a kind of domino effect.

That's what the new top-level await proposal (stage 3 as of the time of this writing) really solves. With it, you can write something like this.

// products.js 
import { fictionalDb } from './fictionaldb.js' 
import { config } from './db-config.js'
 
// connect() is promise returning
let connection = await fictionalDb.connect(config)

export default {
	recent: function () {   
	   // use the connection to return recent products
	}
	
	discontinued: function () {
	   // use the connection to return discontinued products	
	}
}

It seems incredibly small - one word on line 6. However, the magic and value really comes in how this module can now be used: You just import it like any other module.

If you're curious about how it works

The real magic here is largely in the definition of a standard protocol that imports and exports can reason about internally. To explain: You can think about modules as really including both their actual expressed exports, and, a new implicit internal promise used by the module system's internal protocol. While you're expressing your dependencies without that, the module system will (roughly) expand this internally as an implicit Promise.all around anything waiting for export.

For example, given the code:

import { a } from './a.mjs';
import { b } from './b.js';
import { c } from './c.js';

console.log(a, b, c)

The module system, internally, sees (again, roughly) this:

import { _internalPromise as aPromise, a } from './a.js';
import { _internalPromise as bPromise, b } from './b.js';
import { _internalPromise as cPromise, c } from './c.js';

// the module system creates this promise 
// and uses it to know your module is ready.
export const _internalPromise = 
	Promise.all([aPromise, bPromise, cPromise])
		.then(() => {
   			console.log(a, b, c);
		});

The net result is that modules are fetched in parallel and executed in order until the first await, and then waits until they are all complete before resolving the module itself.