Skip to content

Instantly share code, notes, and snippets.

@Rich-Harris
Last active October 8, 2024 15:14
Show Gist options
  • Save Rich-Harris/0b6f317657f5167663b493c722647221 to your computer and use it in GitHub Desktop.
Save Rich-Harris/0b6f317657f5167663b493c722647221 to your computer and use it in GitHub Desktop.
Top-level `await` is a footgun

Edit β€” February 2019

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.


Top-level await is a footgun πŸ‘£πŸ”«

Follow-ups:

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.

Recap: what is top-level await?

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.

That sounds great!

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 );

The catch

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.

Oh, and interop with the existing module ecosystem? Forget it

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.

Objections

It's devs' responsibility not to do daft things. We'll just educate them!

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.

But the syntax is so much nicer!

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.

Interop with CommonJS doesn't really matter

Say that to my face.

You're only saying all this because it makes bundling harder

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.

@dkstrong
Copy link

It sounds like the whole concern is what happens if a library uses a TLA making it take a long time to import?

Doesn't this also apply to ANYTHING that happens in the library import? Anyone making a library needs to consider this even when not using await. For instance you wouldn't want to read a file using fs.readFileSync() at the top level of your script if it's going to be imported as a library in to another script. Instead of you would probably want to export a function that does this long running process.

It, however should be totally possible to call fs.readFileSync() from the top level of your script. Not all scripts get imported as libraries. Some scripts are the actual application script and are invoked directly from the command line. In which case this is the normal and expected thing to do.

The same applies for top level await.

It's up to the developers to decide how to design their programs and to make smart decisions that lead to usable software. The best thing to do totally depends on the program you are writing and how it's meant to be used.

@hinell
Copy link

hinell commented Nov 2, 2023

The linked above proposal-top-level-await got implemented by various browsers & tools:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment