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

yes, they would be deterministic if loaded as a single graph / not in parallel

phew! That's good to hear. In which case, my original concerns stand, and the objections that prompted this gist were off the mark, because they weren't responding to the actual argument. Hey ho.

As per the alternative, I don't think resolution hooks are currently in the path for the type=module specification. The talks the Node CTC also only has very limited ability to interact with resolution.

Granted – my goal here is to argue that dynamic loading could be done with configurable resolution. It doesn't sound like there are any insurmountable technical obstacles to that.

It also requires the entire application and dependency tree be able to export a single config for setting up those mapping prior to any loading operation

This is the obvious objection, yes. I think people typically overstate the configuration burden (perhaps a case of 'once bitten twice shy' after dealing with RequireJS configs – though it's a very different world now). I also think that developers have perhaps become a little too accustomed to tools like Browserify and Webpack sparing them from having to think about this stuff. It really doesn't seem like too much to expect that when you include a dependency in your application, you might have to actually know what it contains and possibly engage in a little light (but standardised and documented) configuration. (In fact devs already do this without complaint in some cases, e.g. having to set up webpack.DefinePlugin for production builds of React, or whatever. It's the same principle.)

A great example of this is a server not being able listen to a port until a DB is being hooked up, or a website waiting on translations prior to rendering components. The uses are varied, and summarizing them as only useful for changing the mappings which imports are pulling from does not cover most of the ones I personally would use it for.

We've managed to build these apps without top-level await so far. await gives us nicer syntax, I totally agree. I think the main place where we differ is on the cost – I think it's too high, and you don't – and on the feasibility of the alternative (which, to be clear, has been proven to work in the form of RequireJS configs, which were a much grubbier version of what I'm talking about).

I definitely want to be able to use imperative imports in my applications. I just don't want my app to be blocked by them.

@bmeck
Copy link

bmeck commented Sep 15, 2016

I just don't want my app to be blocked by them.

await is exactly for when you app is blocked by something. A consumer is trying to use your code (most likely to start up the application), and after some number of dependencies they get to an await because this module cannot be used until some event occurs (notification, dynamic loading, etc.). All sync work and event handlers prior to the await can still run fine. I am saying even if you don't want such workflows to exist, they certainly do like the examples given. Even if you don't use await, nothing can happen until that event occurs.

@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