Skip to content

Instantly share code, notes, and snippets.

@Rich-Harris
Last active March 23, 2022 19:22
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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
@bmeck
Copy link

bmeck commented Sep 14, 2016

it thinks everything would have to be sequentialized.

This is not saying that nothing needs to be sequentialized. In all examples you must complete the module from top to bottom prior to the module itself being declared "initialized".

The various examples in this gist that specifically use the import declaration style of import:

  1. None of these modules were requested to be loaded lazily nor in parallel
  2. None of these modules have runtime information required to determine their location (language / environment / etc.)
  3. None of these modules are optional nor does the program guard against failures to load
  4. None of these modules need resource outside JS (like waiting for a server to start listening / setting up a connection) to become usable

MOST of these hold true for library code, this is unlikely in application code.

  • We are increasingly moving to not ship assets that are unused to the client in a way that blocks first render time.
import {firstRender} from 'first-render';
await firstRender();
import('more-web-components');
  • We are increasingly moving to have internationalization based upon our users.
await import(`i18n/${navigator.language}`);
  • We sometimes have optional dependencies or dependencies we only enable in the test environment.
if (process.env.NODE_ENV === 'development') {
  await import('long-stack-traces');
}
  • We often need to wait on ready events to actually do anything with our code.
import {db} from 'db';
await db.connect();
server.listen(8080);

@Rich-Harris
Copy link
Author

Right, of course import('a'); import('b'); allows a and b to run concurrently – never disputed that. But to the narrow question of whether the order of logs in this example is deterministic...

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

// foo.js
await delay( Math.random() * 1000 );
console.log( 'foo' );

// bar.js
await delay( Math.random() * 1000 );
console.log( 'bar' );

...you're saying that they are? Because that's what I was referring to when I talked about the problem of top-level await causing work to happen sequentially, and it was that characterisation that @domenic appeared to be objecting to. If not then the 'correction' was somewhat disingenuous.

I don't dispute that there are valid uses for dynamic module loading, nor that await is a convenient syntax to deal with them – I just don't think the benefits outweigh the significant costs. I proposed a sketch of an alternative solution here.

@bmeck
Copy link

bmeck commented Sep 15, 2016

@Rich-Harris yes, they would be deterministic if loaded as a single graph / not in parallel. Otherwise you could be working with partially initialized modules. It is mentioned because it is the consumer that decides if it was blocking the initial app code that began loading the thing that awaits. By the time that the await occurs, the entire dependency graph is parsed and linked. All new imports/awaits are behavior that cannot change the shape of the dep graph. Work done here can request external resources, and in fact that is the main purpose of dynamic imports in general.

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. Your solution only solves one aspect of when to use top level await, and that is for known imports to map to new files. 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 causes some problems of separation of concern in my mind and attempts to use configuration to change the behavior of a problem in non-trivial ways. It would seem harder to debug/profile than awaits since the logic of the imports become implicit. It also does not solve the problem of event based awaits where the program cannot continue the current control flow until an event finishes. Additionally, this does not allow for conditional imports like ones importing based upon environment (OS/form factor/development/etc.) to be treated as a first class citizen.

When thinking of await I would consider it an answer to how to depend on non-JS, non-static, or non-sync resources that must exist in order for the program to execute. 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.

@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