awaitThe 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.
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.
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.
let lodash;
try {
lodash = await import('https://primary.example.com/lodash');
} catch {
lodash = await import('https://secondary.example.com/lodash');
}
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.
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.
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:
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.
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:
downloadedText2 is already accessible before done is fulfilled.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).
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.
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.
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.
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.
Support for top-level await:
--harmony-top-level-await. This is the relevant issue.
--harmony-top-level-await works in Node.js 13.3+.await proposal was an important source of this blog post. Is is very well written and quite readable.import()” in “Exploring JavaScript”