Skip to content

Instantly share code, notes, and snippets.

@Rich-Harris
Last active March 23, 2022 19:22
Show Gist options
  • Save Rich-Harris/9a270920e203e6df9477ca02318fb640 to your computer and use it in GitHub Desktop.
Save Rich-Harris/9a270920e203e6df9477ca02318fb640 to your computer and use it in GitHub Desktop.
Non-deterministic module ordering is an even bigger footgun

Non-deterministic module ordering is an even bigger footgun

Update – in the comments below, @bmeck clarifies that statically imported modules do evaluate in a deterministic sequential order, contra the Hacker News comments that prompted this gist.

Another follow-up to Top-level await is a footgun. On the Hacker News comments, Domenic says this:

(The argument also seems to be predicated on some fundamental misunderstandings, in that it thinks everything would have to be sequentialized. See my other replies throughout this thread.)

In other words, if you have an app like this...

// main.js
import './foo.js';
import './bar.js';

// foo.js
await delay( Math.random() * 1000 );
polyfillSomeFeature();

// bar.js
await delay( Math.random() * 1000 );
useThePolyfilledFeature();

...then the order is random, breaking any guarantees you might have thought you had about the order of execution.

Let's talk about another way that could break your app.

Cyclical dependencies

It's not uncommon for cyclical dependencies to exist between modules:

// A.js
import B from './B.js';

export default class A {
  b () {
    return new B();
  }
}

// B.js
import A from './A.js';

export default class B extends A {
  a () {
    return new A();
  }
}

Note that the cyclical dependency is assymetrical – while A.js depends on B.js, B.js can't even execute until A.js has executed. This means you have to be careful about which module gets imported first:

// main.js
import A from './A.js';
import B from './B.js';

Modules are loaded and evaluated in a specific order. In this case, main.js first imports A.js. Before A.js can evaluate, its dependency B.js must be loaded and evaluated. The dependency on A.js is ignored because that module is already in the loading phase, so its exports are undefined – which means that B.js, which can't execute without A.js, fails altogether, and your app blows up.

But because that happens deterministically, it's possible to track down the error and fix it:

// main.js
import B from './B.js';
import A from './A.js';

Cyclical dependencies with top-level await

Now let's throw some asynchronous fairy dust on this whole thing:

// main.js
import A from './foo.js';
import B from './bar.js';

// foo.js
await delay( Math.random() * 1000 );
const A = await import( './A.js' );
export default A;

// bar.js
await delay( Math.random() * 1000 );
const B = await import( './B.js' );
export default B;

Just like that, your entire app has become vulnerable to a race condition (which in a less contrived situation would be a lot more sneaky).

Predictability matters

While it's true that you could blame both situations on the developer (bar.js in the first example should have made its dependency on the polyfill explicit, even though that's not generally how it works, and you could engineer the same race condition with the cyclical dependencies without await), the point here is that we've just made the behaviour of our programs much harder to reason about. These are just two simple examples of the confusion that could arise – now extrapolate that to a large application with many dependencies.

And it still doesn't solve the fundamental problem

...which is that top-level await by its very nature slows down your app startup. It just mitigates it. So you have three choices:

  • Top-level await that slows your app down and has crazy non-deterministic execution
  • Top-level await that slows your app down even more, but at least runs in a predictable way
  • Don't allow top-level await into the language
@Rich-Harris
Copy link
Author

await is exactly for when you app is blocked by something

s/app/module/g. I understand what it's designed for, I'm interested in what it'll be used for. The fundamental problem is that a module author doesn't know how the module is going to be consumed, nor does she have to experience the cumulative effects of the await her module needs plus all the others that are used in a given codebase. Since, as we've seen, that work has to happen sequentially (unless you're unreasonably careful about factoring your app in such a way as to contain those effects), this is an express route to Bloat City. Even if each individual top-level await seems innocent, overall startup performance will succumb to death by a thousand paper cuts.

I am saying even if you don't want such workflows to exist, they certainly do like the examples given

I do want those workflows to exist – just in a way that allows for concurrent activity and localised (i.e. easy to reason about) consequences! Just like we have now.

@bmeck
Copy link

bmeck commented Sep 15, 2016

Even if each individual top-level await seems innocent, overall startup performance will succumb to death by a thousand paper cuts.

I fully concur it should be used when appropriate, just like any other data or control structure. Nesting everything into Promises suffers the same paper cut problem.

We can talk about alternatives but await signals that modules are not ready to be used. I am unsure how we could make that both concurrent safely and have the source/effect localized to a file. Hoisting it out of the dep graph is not possible for all scenarios.

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