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 πŸ‘£πŸ”«


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

      .reduce(function(chain, chapterPromise) {
        return chain.then(function() {
          return chapterPromise;
        }).then(function(chapter) {
      }, 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');
    for (let chapter of {
      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.


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


// code down here can happily execute while all three
// network requests are taking place

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

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 () {
.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


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


jFransham commented Sep 14, 2016

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

@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.

deckar01 commented Jul 9, 2017

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.

You lost me. You spend the whole article (and multiple follow ups) explaining why no one should get top level await, because they won't mix well with dynamic imports, then say they might work if they are only used for dynamic imports.

I think you are underestimating the ability of developers to eliminate slow startup times by patching and forking their dependencies. There are plenty of other ways to do slow synchronous things at startup. Let's not kill proposals due to concerns over tooling optimization.

It's possible to parallelise requests:

;(async () => {
  const [foo, bar, baz] = await Promise.all(['foo', 'bar', 'baz'].map(mod => import(mod)))
  console.log(foo, bar, baz)

jtenner commented Aug 11, 2017

If you want top level await there's no stopping a developer from doing an IAIFE like this.

(async function() {
await import('foo');

Also, what about node.js web applications where that initial load time doesn't even matter slightly?

You can always use an async IIFE, but most of the problems could be solved by making top-level await as mere syntactic sugar for

let result;
(async () => {
  result = await import("./foo");

This way, result would be undefined until the promise is resolved. Of course that'd introduce other kinds of problems, as:

  • what if I want to use const, and
  • that would behave quite differently from lower-level await.

I can only see REPLs allowing top-level await in a relatively safe manner.

masaeedu commented Nov 1, 2017

This is a pretty myopic view, given that JS doesn't exist solely on browsers and top-level await isn't solely for importing things. Top level await is necessary to prevent awkwardness with stack traces when building CLI tools.

I'd argue that the lack of top-level await will lead to at least equally worse issues as having it would.

Let's look at the first solution first. The claim is that the syntax isn't worse, sure I'll give it that but this doesn't respect the fact that if things are already a large tree of mostly synchronous functions then trying to use a module that exports one of these things will definitely explode into the code base causing potentially large amounts of tracing, for example I wanted to a math function provided by a library but because it was distributed as a script there's no way I could transparently introduce into the code base without an async explosion that would require modifying hundreds of files.

Ultimately I just rewrote what I needed using ES modules as it was a less stupid solution that having to convert everything to be async. But this is bad, the choice shouldn't be manually convert an existing library into ES modules or convert considerable parts of your code base to async await. It's so much better using top-level await syntax:

// Suppose the library is math.js
export default await new Promise(resolve => {
    // Create a script tag
    const script = document.createElement('script')
    script.onload =_ => {
        // resolve with the global and clean it from window
        delete window.math
    script.src = "./math-browser.js"

Better yet this is easy to transform into a module that also works on Node.js as well:

const browser = _ => new Promise(resolve => {
    // Create a script tag
    const script = document.createElement('script')
    script.onload =_ => {
        // resolve with the global and clean it from window
        delete window.math
    script.src = "./math-browser.js"

const node = _ => import("mathjs")

export default await (typeof window === 'object' ? browser() : node())

Your other proposed solution isn't really nice either, and it isn't even required in the first place to solve the problem you're trying to solve with it. Now I understand that fast page load is important, but this is entirely the design behind HTTP/2, the potential web standard WebPackage or even just a Service worker (except on the first page load). These technologies are already designed for preloading of resources! we don't need another one that's arbitrarily baked into JavaScript and nothing else (like what about css @import <media query>, html imports, etc?).

TLDR: Not having top-level await makes some use cases considerably more difficult and loading of dynamic resources can happen anyway it's just more cumbersome. The existing solutions such as HTTP/2 solve the issue of loading dependency graphs in the general case not just JavaScript so JavaScript should not restrict its semantics when valuable use cases exist just to satisfy a use-case that is better solved by the mentioned pre-existing solutions.

Ivanca commented Dec 20, 2017

Most problems would be solved by allowing import to take an array (to explicitly load those modules in parallel) and using destructuring for assignment; as in:

{ foo, bar } = import ['./foo.js', './bar.js']
// or for named exports
{ x, y } = import [x from './foo', y from './bar']

I also think dynamic imports should be not allowed; it makes sense for node.js but does not for client applications because it makes network caching and compiled-by-the-browser-code-reutilization extremely difficult.

I see a lot of apps that do fs.readFileSync() to read in config files atm, because they trade in those few milliseconds of startup time vs. having to add code to every request handler or thinking of an advanced dependency injection system. But not every API has a sync equivalent.

Another thing is await in the main module. When writing a CLI, at least in current node versions unhandled Promise rejections do not crash the process. So there is a lot of boilerplate needed to properly run async code, catch errors, log them properly and exit - all of which is not needed with synchronous exceptions.

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