This gist had a far larger impact than I imagined it would, and apparently people are still finding it, so a quick update:
- TC39 is currently moving forward with a slightly different version of TLA, referred to as 'variant B', in which a module with TLA doesn't block sibling execution. This vastly reduces the danger of parallelizable work happening in serial and thereby delaying startup, which was the concern that motivated me to write this gist
- In the wild, we're seeing
(async main(){...}())
as a substitute for TLA. This completely eliminates the blocking problem (yay!) but it's less powerful, and harder to statically analyse (boo). In other words the lack of TLA is causing real problems - Therefore, a version of TLA that solves the original issue is a valuable addition to the language, and I'm in full support of the current proposal, which you can read here.
I'll leave the rest of this document unedited, for archaeological reasons.
Follow-ups:
- Why imperative imports are slower than declarative imports
- Dynamic module loading done right
- Non-deterministic module ordering is an even bigger footgun
As the creator of Rollup I often get cc'd on discussions about JavaScript modules and their semantics. Recently I've found myself in various conversations about top-level await.
At first, my reaction was that it's such a self-evidently bad idea that I must have just misunderstood something. But I'm no longer sure that's the case, so I'm sticking my oar in: Top-level await
, as far as I can tell, is a mistake and it should not become part of the language. I'm writing this in the hope that I really have misunderstood, and that someone can patiently explain the nature of my misunderstanding.
ES2017 will introduce async
and await
, which make it much easier to write a series (take a mental note of that word, 'series') of asynchronous operations. To borrow from Jake:
// this Promise-based code...
function loadStory() {
return getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterURLs.map(getJSON)
.reduce(function(chain, chapterPromise) {
return chain.then(function() {
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
});
}
// ...becomes this:
async function loadStory() {
try {
let story = await getJSON('story.json');
addHtmlToPage(story.heading);
for (let chapter of story.chapterURLs.map(getJSON)) {
addHtmlToPage((await chapter).html);
}
addTextToPage("All done");
} catch (err) {
addTextToPage("Argh, broken: " + err.message);
}
document.querySelector('.spinner').style.display = 'none';
}
Lovely. The intent is much clearer, and the code is more readable. You can use async
and await
in modern browsers via async-to-gen, or, if you need to support older browsers and don't mind a bit more transpiled code, via Babel with the ES2017 preset.
Note that await
can only be used inside an async
function. Top-level await is a proposal to allow await
at the top level of JavaScript modules.
Yes, it does, at first. One of the things some people don't like about JavaScript modules is that you can't load modules dynamically – whereas in Node.js you can do this...
var x = condition ? require( './foo' ) : require( './bar' );
doSomethingWith( x );
...there's no JavaScript module equivalent, because import
declarations are entirely static. We will be able to load modules dynamically when browsers eventually support them...
// NB: may not look exactly like this
import( condition ? './foo.js' : './bar.js' ).then( x => {
doSomethingWith( x );
});
...but as you can see, it's asynchronous. It has to be, because it has to work across a network – it can't block execution like require
does.
Top-level await
would allow us to do this:
const x = await import( condition ? './foo.js' : './bar.js' );
doSomethingWith( x );
Edit: it's actually worse than I thought – see this follow-up
The problem here is that any modules that depend on modules with a top-level await
must also wait. The example above makes this seem harmless enough, but what if your app depended on this module?
// data.js
const data = await fetch( '/data.json' ).then( r => r.json() ).then( hydrateSomehow );
export default data;
You've just put your entire app – anything that depends on data.js
, or that depends on a module that depends on data.js
– at the mercy of that network request. Even assuming all goes well, you've prevented yourself from doing any other work, like rendering views that don't depend on that data.
And remember before, when I asked you to make a note of the word 'series'? Since module evaluation order is deterministic, if you had multiple modules making similar network requests (or indeed anything asynchronous) those operations would not be able to happen in parallel.
To illustrate this, imagine the following contrived scenario:
// main.js
import './foo.js';
import './bar.js';
// foo.js
await delay( Math.random() * 1000 );
console.log( 'foo happened' );
// bar.js
await delay( Math.random() * 1000 );
console.log( 'bar happened' );
What will the order of the logs be? If you answered 'could be either', I'm fairly sure you're wrong – the order of module evaluation is determined by the order of import
declarations.
As far as I can tell (and I could be wrong!) there's simply no way for a CommonJS module to require
a JavaScript module with a top-level await
. The path towards interop is already narrow enough, and we really need to get this right.
No, you won't. You'll educate some of them, but not all. If you give people tools like with
, eval
, and top-level await
, they will be misused, with bad consequences for users of the web.
Hogwash. There's nothing wrong with this:
import getFoo from './foo.js';
import getBar from './bar.js';
import getBaz from './baz.js';
async function renderStuff () {
const [ foo, bar, baz ] = await Promise.all([ getFoo(), getBar(), getBaz() });
doSomethingWith( foo, bar, baz );
}
renderStuff();
// code down here can happily execute while all three
// network requests are taking place
doSomeOtherStuffWhileWeWait();
The version that uses top-level await – where network requests happen serially, and we can't do anything else while they happen – is slightly nicer, but certainly not to a degree that justifies the degraded functionality:
import foo from './foo.js';
import bar from './bar.js';
import baz from './baz.js';
doSomethingWith( foo, bar, baz );
// code down here has to wait for all the data to arrive
apologiseToTheUserForMakingThemWait();
True, dynamic module loading is a bit trickier in this context (though still absolutely possible – also, see dynamic module loading done right). Robert Palmer suggests that a compromise would be to allow the await
keyword at the top level but only for await import(...)
, not anything else. I'm ambivalent about having a keyword mean slightly different things in different places, but this seems worth exploring.
Say that to my face.
No, I'm not – I don't believe that tooling should drive these sorts of conversations (though at the same time, great things can happen when language designers think deeply about tooling and workflow). But yes, since you asked, top-level await
probably will make it harder for tools like Rollup to create really tightly-optimised bundles of code.
Lots of smarter and more experienced developers than me seem to think top-level await
is a great idea, so perhaps it is just me. It seems unlikely that none of this has occurred to people on TC39. But then again maybe joining those echelons involves a transformation that makes it hard to relate to mortals, like Jon Osterman becoming Doctor Manhattan.
Hopefully someone will be able to explain which it is.
Unfortunately this brings whole lot of a problems e.g. in MusicKit JS which instance of it is promise based in v3 and I cannot make it to work even in browser that allegedly support that