ECMAScript proposal: top-level await

[2019-12-19] dev, javascript, es proposal
(Ad, please don’t block)

The ECMAScript proposal “Top-level await by Myles Borins lets you use the asynchronous await operator at the top level of modules. Before, you could only use it in async functions and async generators.

Why await at the top level of a module?  

Why would we want to use the await operator at the top levels of modules? It lets us initialize a module with asynchronously loaded data. The next three subsections show three examples of where that is useful.

Loading modules dynamically  

const params = new URLSearchParams(window.location.search);
const language = params.get('lang');
const messages = await import(`./messages-${language}.mjs`); // (A)

console.log(messages.welcome);

In line A, we dynamically import a module. Thanks to top-level await, that is almost as convenient as using a normal, static import.

Using a fallback if module loading fails  

let lodash;
try {
  lodash = await import('https://primary.example.com/lodash');
} catch {
  lodash = await import('https://secondary.example.com/lodash');
}

Using whichever resource loads fastest  

const resource = await Promise.any([
  fetch('http://example.com/first.txt')
    .then(response => response.text()),
  fetch('http://example.com/second.txt')
    .then(response => response.text()),
]);

Due to Promise.any(), variable resource is initialized via whichever download finishes first.

Why work-arounds aren’t good enough  

In this section, we attempt to implement a module that initializes its export via asynchronously loaded data.

We first try to avoid top-level await via several work-arounds. However, all of those work-arounds have downsides. Therefore we end up with top-level await being the best solution.

First attempt: immediately-invoked top-level async function  

The following module initializes its export downloadedText1 asynchronously:

// async-lib1.mjs
export let downloadedText1;
async function main() {
  downloadedText1 = await asyncFunction();
}
main();

Instead of declaring and invoking an async function, we can also use an immediately-invoked async arrow function:

export let downloadedText;

(async () => {
  downloadedText = await asyncFunction();
})();

Note – we must always wrap the async arrow function in parentheses:

  • The invocation parentheses cannot directly follow the body of the arrow function.
  • Even in expression context, we cannot omit the parentheses around the arrow function.

To see the downside of this approach, let’s try to use async-lib1.mjs:

import {downloadedText1} from './async-lib1.mjs';
assert.equal(downloadedText1, undefined); // (A)
setTimeout(() => {
    assert.equal(downloadedText1, 'Downloaded!'); // (B)
  }, 100);

Directly after importing, downloadedText1 is undefined (line A). We must wait until the asynchronous work is finished before we can access it (line B).

We need to find a way to do this reliably – our current approach is not safe. For example, it won’t work if asyncFunction() takes longer than 100 milliseconds.

Second attempt: notifying importers via a Promise when exports are safe to use  

Importers need to know when it is safe to access the asynchronously initialized export. We can let them know via a Promise named done:

// async-lib2.mjs
export let downloadedText2;

export const done = (async () => {
  downloadedText2 = await asyncFunction();
})();

The immediately-invoked async arrow function synchronously(!) returns a Promise that is fulfilled with undefined once the function terminates. The fulfillment happens implicitly, because we don’t return anything.

Importers now wait for done’s fulfillment and can then safely access downloadedText2:

// main2.mjs
import {done, downloadedText2} from './async-lib2.mjs';
export default done.then(() => {
  assert.equal(downloadedText2, 'Downloaded!');
});

This approach has several downsides:

  1. Importers must be aware of the pattern and use it correctly.
  2. It’s easy for importers to get the pattern wrong, because downloadedText2 is already accessible before done is fulfilled.
  3. The pattern is “viral”: main2.mjs can only be imported by other modules if it also uses this pattern and exports its own Promise.

In our next attempt, we’ll try to fix (2).

Third attempt: putting exports in an object that is delivered via a Promise  

We want importers to not be able to access our export before it is initialized. We do that by default-exporting a Promise that is fulfilled with an object that holds our exports:

// async-lib3.mjs
export default (async () => {
  const downloadedText = await asyncFunction();
  return {downloadedText};
})();

async-lib3.mjs is used as follows:

import asyncLib3 from './async-lib3.mjs';
asyncLib3.then(({downloadedText}) => {
  assert.equal(downloadedText, 'Downloaded!');
});

This new approach is better, but our exports are not static (fixed) anymore, they are created dynamically. As a consequence we lose all the benefits of a static structure (good tool support, better performance, etc.).

While this pattern is easier to use correctly, it is still viral.

Final attempt: top-level await  

Top-level await eliminates all downsides of our most recent approach, while keeping all of the upsides:

// async-lib4.mjs
export const downloadedText4 = await asyncFunction();

We still initialize our export asynchronously, but we do so via top-level await.

We can import async-lib4.mjs without knowing that it has asynchronously initialized exports:

import {downloadedText4} from './async-lib4.mjs';
assert.equal(downloadedText4, 'Downloaded!');

The next section explains how JavaScript ensures under the hood that everything works properly.

How does top-level await work under the hood?  

Consider the following two files.

// first.mjs
const response = await fetch('http://example.com/first.txt');
export const first = await response.text();
// main.mjs
import {first} from './first.mjs';
import {second} from './second.mjs';

assert.equal(first, 'First!');
assert.equal(second, 'Second!');

Both are roughly equivalent to the following code:

// first.mjs
export let first;

export const promise = (async () => {
  const response = await fetch('http://example.com/first.txt');
  first = await response.text();
})();
// main.mjs
import {promise as firstPromise, first} from './first.mjs';
import {promise as secondPromise, second} from './second.mjs';

export const promise = (async () => {
  await Promise.all([firstPromise, secondPromise]);
  assert.equal(first, 'First content!');
  assert.equal(second, 'Second content!');
})();

JavaScript statically determines which modules are asynchronous (i.e., either a direct import or an indirect import has a top-level await). All the Promises exported by those modules (and only those) are passed to Promise.all(). The remaining imports are handled as usually.

Note that rejections and synchronous exceptions are converted as in async functions.

The pros and cons of top-level await  

People already initialize modules asynchronously, via various patterns (some of which we have seen in this blog post). Top-level await is easier to use and makes async initialization transparent to importers.

On the downside, top-level await delays the initialization of importing modules. Therefore, it‘s best to use it sparingly. Asynchronous tasks that take longer are better performed later, on demand.

However, even modules without top-level await can block importers (e.g. via an infinite loop at the top level), so blocking per se is not an argument against it.

Implementations  

Support for top-level await:

Further reading