await
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.
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 “JavaScript for impatient programmers”