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.

@Jamesernator
Copy link

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
        resolve(window.math)
        delete window.math
        
        script.remove()
    }
    script.src = "./math-browser.js"
    document.body.appendChild(script)
})

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
        resolve(window.math)
        delete window.math
        
        script.remove()
    }
    script.src = "./math-browser.js"
    document.body.appendChild(script)
})

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
Copy link

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.

@felixfbecker
Copy link

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.

@josephg
Copy link

josephg commented Jan 24, 2018

My response is simple: My code does much more interesting stuff than import modules. Great; do or don't support async imports. But I want to be able to write simple scripts like this:

const fetch = require('node-fetch')

const somedata = await (await fetch(url1)).json()
const data2 = await fetch(somedata.url)
// ...

And I want to be able to write that without wrapping all my actual code in a useless async function wrapper.

@lqs469
Copy link

lqs469 commented Feb 1, 2018

Support

const one = await fetch(url) 
const mutliple = await.all([fetch(url1), fetch(url2)])

@benjamingr
Copy link

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.

This actually convinced me - as a member of the Node.js module team - what would you do for cases like nodejs/modules#7 ?

@nuxodin
Copy link

nuxodin commented May 24, 2018

What about my Proposal "async blocks"

async { await fetchData(); }

That desugars to:

(async () => {  await fetchData();  })()

@qm3ster
Copy link

qm3ster commented Aug 28, 2018

Is this bad?

async function renderStuff () {
  const [ foo, bar, baz ] = await Promise.all(['./foo.js', './foo.js', './foo.js'].map(url=>import(url).then(module=>module.default())));
  doSomethingWith( foo, bar, baz );
}

renderStuff();

// code down here can happily execute while all three
// network requests are taking place
doSomeOtherStuffWhileWeWait();

I'd imagine it's better, since we get to doSomeOtherStuff while those are downloading, not just download and parse them in parallel but wait for them all to finish before we can doSomeOtherStuff?

@somombo
Copy link

somombo commented Dec 2, 2018

I'm curious to hear @MylesBorins comments / opinions regarding this gist. I'm in support of top-level await if the Optional Constraint is enforced in it's implementation. I'd really like to see denoland/deno#471 quashed.

@melkishengue
Copy link

I don't think it is the responsibility of javascript to prevent devs from writing bad code... Any way if they really want to use top level await, they can do it by wrapping it inside an IIFE, which basically results into the same effect of blocking any module depending on the module being loaded

@noyearzero
Copy link

Just a general though regarding the argument for trying to protect developers from themselves... It's kind of like saying "since people have the ability to drink and drive, alcohol should be outlawed." Would it save some lives? Sure, but you also inconvenience those who can use it responsibly. You also haven't prevented cases where people obtain alcohol illegally or use their car recklessly while sober.

PHP attempted the same paradigm when they introduced 'magic quotes'. The idea was that all GET and POST data automatically had special characters quoted so when put into SQL statements query injection would be mitigated. They assumed this would protect bad or inexperienced programmers and help them write safer code. In the short term it helped, but in the long run it ended up lulling developers into a false sense of security. It ended up creating more problems, didn't full protect, and didn't help developers be better. In then end they removed this feature.

If you let this kind of thinking hold progress back it does everyone a disservice and you'll just end up stressing yourself out over someone else's problem.

@hayd
Copy link

hayd commented Sep 25, 2019

Top level await is now available in V8 (and deno).

@lin7sh
Copy link

lin7sh commented Oct 15, 2019

@hayd thanks for the heads up. Is it ready for simple test and how to enable it?

@joeyhub
Copy link

joeyhub commented Jan 2, 2020

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

I have to disagree with this sarcastic sentiment. It was trying to fool proof things and babysit over educating that broke async/await. If you can remove pitfalls without compromise then fair enough but otherwise you're just busting up the system in a lost cause to save the kind of person that wouldn't be safe in a padded room. The language can become a laughing stock as it starts to take on the form of its most hamfisted users sinking to their level rather than doing the best that can be done to level them up and being done with it. People will make mistakes, you shouldn't feel responsible all the time. There's a threshold somewhere that if you cross you'll be trying to maintain a standard that's impossible.

I'm not sure I see any definite problems presented, none for me anyway.

I would generally agree with the sentiment however that for front end where people are far more likely to use it such as with your example for xhr stuff and they're going to potentially cause some very annoying problems with error handling. I bet you someone will have import 'when_ready'; as a lazy way to run the script only when the body is ready. That will block all dependants from running and someone will have the wrong program flow already so people will do weird things like import to resume running something else not needing to wait for ready. Now that I have suggested it, it will happen.

Such people mess things up anyway and I've long reached the point of just fixing it or throwing it out. Routinely working on millions of lines of code of legacy often from people who do not know how to program paints a very different and realistic picture of the pointlessness of haggling over something that is going to do nothing to cure all ills in software development, it wont even be a drop in the ocean. That's what DailyWTF is for though so it's all in hand.

The way static imports work, you're not really supposed to be depending on order. I'd like to see the use case for that.

Normally if b has to be after a then rather import a then b, a would import b. I think the only case I have like that is potentially of variable dependency and a global, such as which window object to use which has to be set to global because of scripts you have to use but that you can't plug it into.

I already developed my own system for async loading. If I put !async at the end of an import it assumes a module with only a default and awaits until it's no longer a promise. Ironically though, there seems to be a bug with this ATM when loading a specific CJS script (possibly while also loading the whole module elsewhere). I don't mind little CJS to MJS but broken MJS to CJS might be more problematic. I'd hope for MJS for anything new and old would not tend to depend on newer so CJS to MJS would not want to be as extensive.

Funnily enough my system also utilises rollup. It has a set of generic builders which rollup uses and a custom loader. However, the !async (would probably make more sense to have called it !await) thing isn't something I involved rollup in. It only really made sense to me on the backend.

I noticed a lot of the import architecture is already async. On the backend it's actually nonsensical that you have to await await and await again on importing sometimes. I think the reason it doesn't cause me too many headaches is because I use it sparingly only when it makes sense.

It's basically a no contest that I need it, module imports will already be async for me as I compile on demand with the loader for convenience and that often uses async stuff but also if I have some relatively commonly used module deep down in the dependency tree then all its dependants end up having to be async, you suddenly have 5% making the other 80% have to await their static imports anyway. Even dynamic imports you end up with await await import(); My kludge solutions aren't making it pleasure where I might want to export a promise.

It reached a point I started to think there should be an await_until_unawaitable keyword. It might also be worth considering the main reason for not having top level await typically owes more in many implementations to the way async is normally implemented traditionally depending on a special scope such as being inside a generator rather than out of any real design choice. My custom implementations all have had no top level only because the mechanisms used to implement them happened to not lend well to that nor have access to bootstrap the default scope. Often it made no natural sense for scripts that were entirely async based where the whole existence of the initial scope would virtually always be to switch into an async scope.

If people are going to look for problems I think it needs to be technical problems that takes precedence. Not making out like PICNIC is our own personal responsibility. I don't have a problem to the concept but people have implemented these things wrong before because of getting caught up in subjective rather than objective concerns and not focusing on the technical problem which also tends to include over looking less common but actually valid use cases so it needs scrutiny. I don't think this being a footgun is such a problem but other well known footgun's might be such as rushing an incomplete or ill considered specification out of the door that then can't change while relying on blind procedure to assume correctness over expert consultation, prototyping, experimentation, beta testing, etc.

I think there might be a small mind the gap scope for confusion around state that might exist with any import returning something like a persistent connection. That is, you import the thing to get the resource, then something else is allowed to happen, then the resource enters an oh no state such as dead connection then when you use if from imported the first time expecting it to be in an ok state it might not be.

@ivan-kleshnin
Copy link

How terribly this aged.

@noyearzero
Copy link

Yeah, I guess the lesson is that footguns can be useful for things other than shooting yourself in the foot. Just don't point it at your foot.

@Rich-Harris
Copy link
Author

@ivan-kleshnin it directly led to a variant of TLA that has the same ergonomic advantages without the main problem that this gist identified. it's probably one of the most impactful things i've written. i'd say it aged pretty well.

@salesh
Copy link

salesh commented Oct 29, 2020

Amazing gist and valuable resource to read about - it really did make a big impact.
Thank you for this Richard!

@determin1st
Copy link

exports where

@alexp1917
Copy link

We were warned about top level await but it was allowed anyway, quite unfortunate...

@Behinder
Copy link

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

@castrix
Copy link

castrix commented Jan 7, 2022

This didn’t age well

@Rich-Harris
Copy link
Author

@castrix it aged just fine, but thank you for your valuable and unique input

@nopeless
Copy link

nopeless commented Apr 14, 2022

@Rich-Harris thanks for writing this. Specifically this point was impactful for me. I still think that it can be a useful tool in writing quick demos (like eval(input()) in python)

https://gist.github.com/Rich-Harris/0b6f317657f5167663b493c722647221#its-devs-responsibility-not-to-do-daft-things-well-just-educate-them

There are already dynamic imports via import() and a top level await will bring a lot of misunderstanding and bad practices for new developers imo.

Edit: I found out that await was already implemented. I have been using Node js esm for like a year now and only just noticed

@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