Skip to content

Instantly share code, notes, and snippets.

@Rich-Harris
Last active May 6, 2024 10:23
Show Gist options
  • Save Rich-Harris/41e8ccc755ea232a5e7b88dee118bcf5 to your computer and use it in GitHub Desktop.
Save Rich-Harris/41e8ccc755ea232a5e7b88dee118bcf5 to your computer and use it in GitHub Desktop.
Why imperative imports are slower than declarative imports

Why imperative imports are slower than declarative imports

A lot of people misunderstood Top-level await is a footgun, including me. I thought the primary danger was that people would be able to put things like AJAX requests in their top-level await expressions, and that this was terrible because await strongly encourages sequential operations even though a lot of the asynchronous activity we're talking about should actually happen concurrently.

But that's not the worst of it. Imperative module loading is intrinsically bad for app startup performance, in ways that are quite subtle.

Consider an app like this:

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

foo();

// foo.js
import bar from './bar.js';
import a from './a.js';
import b from './b.js';

export default function foo () {
  bar( a + b );
}

// bar.js
import c from './c.js';
import d from './d.js';

export default function bar ( x ) {
  console.log( c + d + x );
}

// a.js
export default 1;

// b.js
export default 2;

// c.js
export default 3;

// d.js
export default 4;

When main.js is loaded and parsed, we can immediately determine (without having to run the code) that it has a dependency on foo.js, so we start loading that. Once it arrives, we see it depends on three other modules (bar.js, a.js and b.js), so they start loading concurrently. Whenever bar.js is loaded, we can set off loading c.js and d.js.

So from main.js, there's just three hops – the depth of the dependency graph (main -> foo -> bar -> c/d) – to load the entire app.

What if some of those imports were imperative?

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

foo();

// foo.js
import bar from './bar.js';
import a from './a.js';

const b = await import( './b.js' ); // <-- imperative

export default function foo () {
  bar( a + b );
}

// bar.js
import c from './c.js';

const d = await import( './d.js' ); // <-- imperative

export default function bar ( x ) {
  console.log( c + d + x );
}

// a.js
export default 1;

// b.js
export default 2;

// c.js
export default 3;

// d.js
export default 4;

Even though this is basically the exact same app, something curious has happened. We load foo.js, as before, triggering a subsequent load of bar.js and a.js (but not b.js, because we don't execute the code until all the dependencies have been loaded and evaluated). As soon as bar.js comes in, we load c.js (but not d.js).

The same three hops (main -> foo -> bar -> c), and we've got all the dependencies that are declared statically. Now for the next phase – evaluation. Evaluation order is guaranteed by the order of import declarations, so we start with c.js. Then we can evaluate bar.js. That's when we hit the await import('./d.js'). Evaluation pauses until the fourth load has completed and d.js has itself been evaluated. Then, with bar.js done, we can move on to foo.js, whereupon we hit await import('./b.js') and have to wait for a fifth load to happen. We load and evaluate b.js, then finish evaluating foo.js, then finally we can actually run our app by evaluating main.js.

We've gone from three 'waves' of module loads (the depth of the dependency graph, i.e. the distance from the entry point to the deepest dependency) to five – the depth, plus the number of imperative imports, since they have to happen sequentially. (And that's before we account for any dependencies those imperatively imported modules might have.)

Now imagine that in a less contrived situation, where you have dozens or hundreds of modules – it only takes a handful of await import(...) statements to seriously slow down your app startup. Let's say an app with 100 modules has a depth of, say, 6. It only has to have 6 imperative imports (fewer, if those modules have dependencies of their own!) and you've just doubled the length of time it will take to load all the modules to start your app!

Gross oversimplification? Possibly. The point stands – declarative imports are faster by their very nature.

And yes, there is a way to achieve dynamic module loading with declarative import declarations.

A couple of observations

  • require(...) is imperative, and thus subject to the same logic as we've described above. JavaScript modules will make your apps faster.
  • With or without HTTP2, dependency graph depth is a factor in startup time. You can reduce your dependency graph depth to zero by bundling your app. (Yes, that also applies to Node apps).
  • Of course, bundling everything prevents you from taking advantage of concurrency and caching. The optimal HTTP2 strategy is probably to bundle chunks of your app, e.g. leaving large third-party dependencies to CDNs.

Corrections welcome.

@gopi-suvanam
Copy link

My take away from this gist:

Of course, bundling everything prevents you from taking advantage of concurrency and caching. The optimal HTTP2 strategy is probably to bundle chunks of your app, e.g. leaving large third-party dependencies to CDNs.

I hope it will be that and not the node way of downloading third-party dependencies and bundling them also together.

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