Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
Top-level `await` is a footgun

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.

I support your argument. We should manage asynchronous imports differently β€” ideally, through a low-level system where code that imports a module continues running instantly, and is only paused when it actually needs to execute (or read from) something in that dependency.

We definitely shouldn't be making it easier for people to take an asynchronous programming paradigm, and misuse it to make their programs behave synchronously, for the sake of making asynchronous code read synchronously... From what I remember about the module loader proposals, isn't System.import intended to solve the problem of asynchronous module loading, anyway? Adding support for top-level await seems like a preemptive bandaid over a feature that's already intended for the language, which will introduce potential bad side-effects depending on how it's used... And if that bandaid is only left in place until System.import is finalized and supported, its removal is just going to cause headaches further down the line... Better to never include that functionality in the first place, and let people write some (only slightly) more verbose code.

Of course, await can be used improperly at lower levels also, and that's something people are just gonna need to learn proper patterns around. E.g., please do not do this if your calls are unrelated to each other:

async function foo () {
const dep1 = await asyncProcess1();
const dep2 = await asyncProcess2();
console.log(dep1, dep2);
}

Do something like this instead:

async function bar () {
Promise
.all([asyncProcess1(), asyncProcess2()])
.then(([dep1, dep2]) => {
console.log(dep1, dep2);
});
}

Just because the syntactic tooling allows for writing terse code, performance still needs to be taken into account. As mentioned in the gist, you're compounding your load times instead of letting them run in parallel. In the examples above, assuming both asyncProcess1 and asyncProcess2 take 1 second each to run, foo takes 2 seconds to execute, whereas bar only takes 1 second, because the processes are executed in parallel.

A single slow-running module is problematic enough, but should be fairly easy to figure out and debug... If we allow that functionality to be invoked at the top level, we're now allowing that behavior to propagate up the dependency chain to the module-loading level itself, which will be much more difficult to isolate the cause of, and will potentially compound load times for an entire application to a ridiculous extent.

zxymgl commented Sep 12, 2016

nice, I'm looking forward to it

Owner

Rich-Harris commented Sep 12, 2016

@Velveeta strongly agree with your comments re using await correctly when you have stuff that can happen concurrently. I wonder if linters could take care of this (i.e. if the return value of the first await expression isn't used before the second await expression, complain)

@Rich-Harris I would certainly think they could. I know they're able to catch things like unnecessary braces in fat arrow functions when there can just be an implicit return, so there's definitely some read-ahead logic they're entirely capable of doing.

πŸ‘

mstade commented Sep 14, 2016

Also, not to forget, async import would hide any potential syntax errors or errors in loading, since promises silence exceptions unless explicitly handled. Unless, of course, there's a special exception (no pun intended) to how promises work for imports. I presume, using some naive extrapolation and guessing, that you'd have to use the following syntax to catch these:

try {
  const x = await import('some/module')
} catch (e) {
 // Honestly I don't even know what to do now I think I accidentally the whole thing
}

And this would get even worse with people using await defensively – that is, people using await everywhere because there's no way to know at author time whether something is async, so you may as well just sprinkle awaits everywhere just to be safe. Something like so:

await import('a')
await import('b')
await import('c')

Which, again, would swallow exceptions because (I belive) this will wrap everything in promises and so therefore: exceptions swallowed. So you'd be looking at something like this:

try { await import('a') } catch (e) { doSomethingWithModuleError(e) }
try { await import('b') } catch (e) { doSomethingWithModuleError(e) }
try { await import('c') } catch (e) { doSomethingWithModuleError(e) }

Lovely.

jFransham commented Sep 14, 2016 edited

My personal view on this is that

await import('foo');

should be allowed, and therefore top-level await should always be allowed. JavaScript isn't Rust, it isn't Idris. Preventing bad programmers from writing bad code is not one of its mission statements. It's about granting large amounts of flexibility, while lending more weight to language-level standards for ergonomics instead of extremely general language features that allow for building standards on top of (as opposed to, say, Python or Lisp). If there is one valid usecase for a feature I would add it, and avoid special-casing as much as possible since JavaScript is already a language with difficult semantic special cases.

In favor of static analysis: top-level await shall not pass! πŸ‘

While this can create some foot-guns it also allows some very powerful optimizations such as importing a module asyncly and then awaiting it latter when needed

var importProm = import('module/i/need/latter');
//...later on
var foo;

if (something) {
    foo = foo || await importProm;
    // do something with foo
}

people can stick a while(true){} loop in a module, most of us still file while loops an acceptable feature despite their ability to slow code down.

acjay commented Sep 14, 2016 edited

@Velveeta The parallel loading could be done maybe a bit more idiomatically like:

async function bar () {
  const [dep1, dep2] = await Promise.all([asyncProcess1(), asyncProcess2()])
  console.log(dep1, dep2);
});
}

It would be kind of cool though if await treated an array of promises that way automatically. But I'm not holding my breath!

@acjay Thanks for collapsing that, that makes it look even more simple to run those processes in parallel πŸ‘

bergus commented Sep 24, 2016

What will the order of the logs be? If you answered 'could be either', I'm fairly sure you're wrong

I don't see anything wrong with that. Asynchronous module initialisation should be concurrent - just like two calls to async functions are. The module that imports both of them (main.js) would wait for them as if using Promise.all. The first module (foo.js) evaluation would still be started first, it just doesn't need to finish before the initialisation of unrelated modules (like bar.js). If there is such a dependency, it would need to be explicitly declared, causing sequential order (just like in the synchronous case).

This concurrency is obviously necessary to keep the speed of app initialisation.

Interop with the commonjs module ecosystem?

That seems to be fairly trivial to me. Just like we convert ES6 modules to module.exports containing a module namespace object, we would convert ES9 (?) modules with top-level await to module.exports containing a promise for that very module namespace object.

I didn't even realize top level await was being considered. How can average js devs speak out against this?

unional commented Mar 22, 2017

we need to be aware of the differences between "load-time" and "runtime". During load time, you should not do anything runtime work. It makes your code not testable.

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