Update – in the comments below, @bmeck clarifies that statically imported modules do evaluate in a deterministic sequential order, contra the Hacker News comments that prompted this gist.
Another follow-up to Top-level await is a footgun. On the Hacker News comments, Domenic says this:
(The argument also seems to be predicated on some fundamental misunderstandings, in that it thinks everything would have to be sequentialized. See my other replies throughout this thread.)
In other words, if you have an app like this...
// main.js
import './foo.js';
import './bar.js';
// foo.js
await delay( Math.random() * 1000 );
polyfillSomeFeature();
// bar.js
await delay( Math.random() * 1000 );
useThePolyfilledFeature();
...then the order is random, breaking any guarantees you might have thought you had about the order of execution.
Let's talk about another way that could break your app.
It's not uncommon for cyclical dependencies to exist between modules:
// A.js
import B from './B.js';
export default class A {
b () {
return new B();
}
}
// B.js
import A from './A.js';
export default class B extends A {
a () {
return new A();
}
}
Note that the cyclical dependency is assymetrical – while A.js
depends on B.js
, B.js
can't even execute until A.js
has executed. This means you have to be careful about which module gets imported first:
// main.js
import A from './A.js';
import B from './B.js';
Modules are loaded and evaluated in a specific order. In this case, main.js
first imports A.js
. Before A.js
can evaluate, its dependency B.js
must be loaded and evaluated. The dependency on A.js
is ignored because that module is already in the loading phase, so its exports are undefined – which means that B.js
, which can't execute without A.js
, fails altogether, and your app blows up.
But because that happens deterministically, it's possible to track down the error and fix it:
// main.js
import B from './B.js';
import A from './A.js';
Now let's throw some asynchronous fairy dust on this whole thing:
// main.js
import A from './foo.js';
import B from './bar.js';
// foo.js
await delay( Math.random() * 1000 );
const A = await import( './A.js' );
export default A;
// bar.js
await delay( Math.random() * 1000 );
const B = await import( './B.js' );
export default B;
Just like that, your entire app has become vulnerable to a race condition (which in a less contrived situation would be a lot more sneaky).
While it's true that you could blame both situations on the developer (bar.js
in the first example should have made its dependency on the polyfill explicit, even though that's not generally how it works, and you could engineer the same race condition with the cyclical dependencies without await
), the point here is that we've just made the behaviour of our programs much harder to reason about. These are just two simple examples of the confusion that could arise – now extrapolate that to a large application with many dependencies.
...which is that top-level await by its very nature slows down your app startup. It just mitigates it. So you have three choices:
- Top-level
await
that slows your app down and has crazy non-deterministic execution - Top-level
await
that slows your app down even more, but at least runs in a predictable way - Don't allow top-level
await
into the language
This is not saying that nothing needs to be sequentialized. In all examples you must complete the module from top to bottom prior to the module itself being declared "initialized".
The various examples in this gist that specifically use the import declaration style of import:
MOST of these hold true for library code, this is unlikely in application code.