DEV Community

Cover image for How to make your app indefinitely lazy – Part 2: Dependency Graphs
Aleksandrovich Dmitrii
Aleksandrovich Dmitrii

Posted on

How to make your app indefinitely lazy – Part 2: Dependency Graphs

Well, hello there! And welcome to part 2 of my ultimate guide! Brace yourself, because you are about to become a real pro.

⏱️ Reading time: ~25-30 minutes
🎓 Level: Advanced

Series Contents:

  1. How to make your app indefinitely lazy – Part 1: Why lazy loading is important
  2. How to make your app indefinitely lazy – Part 2: Dependency Graphs
  3. How to make your app indefinitely lazy – Part 3: Vendors and Cache
  4. How to make your app indefinitely lazy – Part 4: Preload in Advance

Earlier we discussed the very basics of lazy loading and why it is important at all. And in this article, we will cover the following:

  1. How bundlers (i.e. Webpack) analyze source code files, build dependency graphs and generate files for the assembly.
  2. How they generate JavaScript files from the source code.
  3. How browsers decide which generated files should be downloaded to display a lazy page/component.
  4. And how we can decrease the size and number of downloaded files by correctly setting up the structure of files and correctly using static imports.

Experiments with Webpack and dependency trees

Let's begin our deep dive with a few thought experiments. We'll use the same pet app from the Part 1. Right now, the application is built into 3 JavaScript files: the initial chunk and 2 lazy loaded chunks, each responsible for each of the pages we have.

.

Experiment #1 - a shared component

Let's make each of the 2 lazy loaded pages use the same shared component - LargeComponent. Its definition is stored in a separate file: large.tsx.

Chapter1.tsx
import React from 'react';
import { LargeComponent } from '../../components/large';

export default () => (
  <>
    <section className="page">
      <h2 style={{ margin: 'auto' }}>Chapter 1</h2>
      <LargeComponent />
    </section>

    <Content />
  </>
);

function Content() {
  // 2000 lines of code
}
Enter fullscreen mode Exit fullscreen mode

Chapter2.tsx
import React from 'react';
import { LargeComponent } from '../../components/large';

export default () => (
  <>
    <section className="page">
      <h2 style={{ margin: 'auto' }}>Chapter 2</h2>
      <LargeComponent />
    </section>

    <Content />
  </>
);

function Content() {
  // 2000 lines of code
}
Enter fullscreen mode Exit fullscreen mode

Now, I have a question for you. What do you think: how many chunks will be generated and how will they be downloaded by the browser when we try to open these pages?

I bet this one was easy. Webpack will generate 4 files.

.

And the behaviour will be as follows. When we try to open page "Chapter 1", the browser will download 3 chunks: the initial chunk, "chapter1.chunk.js" and the new chunk. Likewise, it works if we first open "Chapter 2": the initial chunk, "chapter2.chunk.js" and the new chunk.

.

You may notice that Webpack just generated a lazy-loaded chunk on its own. However, we imported this component statically and did not use any dynamic imports. Why did it happen?

Webpack analyzed our application and found that 2 lazy components use a shared dependency and created a new shared chunk to optimize loading time. Any time the browser needs to download any of those 2 pages, it must download this additional chunk as well. But the good part is that once the browser downloads this additional chunk, it will not download it again to display the second page. For example, if we opened Chapter 1 once and now are trying to open Chapter 2, the chunk will not be downloaded again.

I also want to highlight here that sometimes developers are confused and believe that they should use dynamic imports for shared dependencies of lazy-loaded components to manually split those shared dependencies into lazy loaded chunks. So they could additionally optimize loading time. But this is actually bad practice. We will discuss proper ways to optimize the number of downloaded files and how to avoid downloading duplicates later in this article. But for now, you should keep in mind:

ℹ️ You should use dynamic imports only for those components which can be displayed lazily themselves, but almost never for their shared dependencies. Webpack is capable of efficiently splitting your bundle into separate chunks on its own.

Experiment #2 - how about a tiny shared component?

Now, let's make the shared component intentionally small. Will the result be any different?

Chapter1.tsx
import React from 'react';
import { TinyComponent } from '../../components/tiny';

export default () => (
  <>
    <section className="page">
      <h2 style={{ margin: 'auto' }}>Chapter 1</h2>
      <TinyComponent />
    </section>

    <Content />
  </>
);

function Content() {
  // 2000 lines of code
}
Enter fullscreen mode Exit fullscreen mode

Chapter2.tsx
import React from 'react';
import { TinyComponent } from '../../components/tiny';

export default () => (
  <>
    <section className="page">
      <h2 style={{ margin: 'auto' }}>Chapter 2</h2>
      <TinyComponent />
    </section>

    <Content />
  </>
);

function Content() {
  // 2000 lines of code
}
Enter fullscreen mode Exit fullscreen mode

It will. Now, Webpack generates only 3 files. Just like in the case, when lazy pages didn't have any shared components.

.

But why? What happened to the TinyComponent? The truth might terrify some of you. Webpack copied and pasted the content of TinyComponent into both Chapter1.chunk.js and Chapter2.chunk.js! That means the same code will be downloaded each time we download any of these files.

It sounds concerning, because we may end up downloading the same code twice. But that's actually a reasonable strategy. In real projects, we might have hundreds of source files used across dozens of lazy-loaded chunks. If Webpack created a separate chunk for every tiny file, we could end up with hundreds or even thousands of generated files. Can you imagine making browsers download hundreds of files to display a single page? Browsers struggle with a large number of requests. In fact, for a single domain name, even if we use the HTTP/3 connection, they will download around a dozen files in parallel at a time. Therefore, creating such tiny files can drastically decrease our performance.

You should be aware that Webpack may decide to create duplicates of code in your assembly. And even though you can fix, it doesn't mean you should do it.

ℹ️ For small shared dependencies, Webpack will not generate a separate chunk, and instead will copy-paste its code into other existing chunks. And it is a reasonable strategy, so you should not try to "fix" it.

Experiment #3 - two shared components: large and tiny

But what if we use both of these components in both pages?

Chapter1.tsx
export default () => (
  <>
    <section className="page">
      <h2 style={{ margin: 'auto' }}>Chapter 1</h2>
      <TinyComponent />
      <LargeComponent />
    </section>

    <Content />
  </>
);
Enter fullscreen mode Exit fullscreen mode

Chapter2.tsx
export default () => (
  <>
    <section className="page">
      <h2 style={{ margin: 'auto' }}>Chapter 2</h2>
      <TinyComponent />
      <LargeComponent />
    </section>

    <Content />
  </>
);
Enter fullscreen mode Exit fullscreen mode

Generated chunks are very similar to experiment #1.

.

The question though is there any difference to TinyComponent this time? And the answer is "yes". Now, the code of this component will be included only once: in the additionally generated chunk - 595.chunk.js.

Just like in the example #1, Webpack managed to create a separate chunk for storing LargeComponent. But this time, during the analysis of the dependency graph, Webpack understood that TinyComponent is also a shared dependency and it used exactly in same lazy components as LargeComponent. And therefore, Webpack merged them into a single file.

And what I wanted to highlight here is that Webpack is quite intelligent when it comes to lazy loading. In other words, we can rely on Webpack to handle lazy loading efficiently. But we need to help it a little bit to improve its efficiency.


Dependency Graph

So, how does it work? When we are talking about lazy loading, we must start thinking in terms of dependency graphs. Webpack analyzes our app's source files and generates dependency graphs for the code. Then it generates JavaScript files, each with its own execution dependency graph.

Let's take a look at the graph for our example: two lazy-loaded pages and two shared components.

.

  • We have 6 files in total: large.tsx, tiny.tsx, Chapter1.tsx, Chapter2.tsx, and App.tsx. And files Chapter1.tsx and Chapter2.tsx are loaded lazily. We also have 2 external dependencies: react and react-dom.
  • Webpack starts analysis of our application from the "entry" file: App.tsx. It sees that App.tsx has a few static imports - react, react-dom, and Title.tsx, - and includes all of them into the initially loaded chunk: main.js.
  • Then, it creates lazy loaded chunks for each of the dynamically imported modules: chapter1.js and chapter2.js.
  • And then, it analyzes all shared dependencies, and it sees that "large.tsx" and "tiny.tsx" are both used in Chapter1 and Chapter2. Therefore, it combines them into a single "shared.js" file. Alternatively, if only "tiny.tsx" is used, like in Experiment #2, its content will be copy-pasted in both lazy chunks.
  • In the end, Webpack creates a new execution graph. So, when the browser is able to download main.js on its own, but must download shared.js any time it needs to download any of chapter1.js or chapter2.js

Picturing such dependency graphs is very useful when we are trying to understand how many chunks our app will generate, and how many of them will be downloaded in certain conditions. But why should we care about it?


Beware of the static imports' weight

When we are implementing lazy loading, using dynamic imports is not even half the story. How we structure our files and manage the dependency tree is just as important, and sometimes even more so.

Let's extend our example. Imagine, both Chapter1 and Chapter2 use the same component. Initially, this component was used only in Chapter1, and to save some development time, we decided to export this component from the "Chapter1.tsx" file instead of creating a new separate file. It's quite common practice to stumble across, at least in my experience.

Chapter1.tsx
import React from 'react';
import { LargeComponent } from '../../components/large';
import { TinyComponent } from '../../components/tiny';

export const AnotherCommonComponent = () => (
  <div style={{ color: 'blue', fontWeight: 500 }}>
    Another common component
  </div>
);

export default () => (
  <>
    <section className="page">
      <h2 style={{ margin: 'auto' }}>Chapter 1</h2>
      <TinyComponent />
      <LargeComponent />
      <AnotherCommonComponent />
    </section>

    <Content />
  </>
);

function Content() {
  // 2000 lines of code
}
Enter fullscreen mode Exit fullscreen mode

Chapter2.tsx
import React from 'react';
import { AnotherCommonComponent } from '../chapter-1/Chapter1';
import { LargeComponent } from '../../components/large';
import { TinyComponent } from '../../components/tiny';

export default () => (
  <>
    <section className="page">
      <h2 style={{ margin: 'auto' }}>Chapter 2</h2>
      <TinyComponent />
      <LargeComponent />
      <AnotherCommonComponent />
    </section>

    <Content />
  </>
);

function Content() {
  // 2000 lines of code
}
Enter fullscreen mode Exit fullscreen mode

And… now our lazy loading has become much less efficient. Even if the shared component was tiny and trivial, we've broken it. If we take a look at the generated files, we'll see that for some reason, now we only 3 files are generated. And for some reason, the content of large.tsx is included in Chapter1.chunk.js.

.

But why? Let's take a look at the dependency tree to understand it.

.

The graph became messier. And the more complex the graph is, the harder it is for Webpack to handle it efficiently. Webpack still generates the same main.js, and still generates files chapter1.js and chapter2.js. However, what changed is that when Webpack analyzed shared dependencies, it saw that now 3 files (Chapter1.tsx, large.tsx, and tiny.tsx) have become shared dependencies between Chapter1.tsx and Chapter2.tsx, and combines them into a single file. And since Chapter1.tsx already has a generated chunk of its own, Webpack includes all these 3 files into Chapter1.chunk.js.

Hence, the execution graph has changed significantly. When a user opens Chapter1, the browser downloads only that its chunk. However, when a user opens Chapter2, the browser ends up downloading both Chapter1.chunk.js and Chapter2.chunk.js.

.

Beware! Our initial intention was to boost the loading time. But in reality, for Chapter2, we didn't achieve that. The browser ended up downloading thousands of lines of code from Chapter1.tsx that will not be used on page Chapter2.

Doesn't tree-shaking help with lazy loading?

You might have a question: shouldn't tree-shaking have fixed that? The answer for that is simple: "no, it shouldn't". When we work with lazy loading, tree-shaking should be treated as a different mechanism. Its goal isn't about code-splitting, and it happens before it. With tree-shaking, bundlers are able to remove unused exports from a file. E.g. if Chapter1 exported a bunch of functions that wouldn't be used anywhere, tree-shaking would remove them. But since both AnotherSharedComponent and the default export of Chapter1.tsx were used, tree-shaking couldn't do anything.

ℹ️ Webpack operates files and cannot treat exports from a single file as separate dependencies.

Therefore:

📌 Do not rely on tree-shaking. Split your files manually to ensure effective lazy loading.


Realistic bad dependency graph examples

For now, it might feel a bit abstract. How can we use this knowledge in real projects? I will show you an example of how bad dependency tree was on a project I currently work.

.

This depiction is not 100% accurate. However, it does highlight its problems:

  • Components from “Component group 1” by design should have been used only in pages “Page1” and “Page2”.
    • At the same time, Page1 and Page2 had some unique non-shared components, yet they were stored in a shared space.
  • Components from “Component group 2” and some related to them NPM packages should've been used only in pages “Page 3”, “Page 4”, and “Page 5”.
  • And each of the pages should have been as independent as possible.

However, in reality, when any of these pages was loaded, the browser had to download almost all the generated files. Even if a component from “component group 2” or “npm-package-1” was not used in “Page1”, their code still was loaded when users opened this page. And these problems existed only because of the broken dependency tree.


Keeping dependency graph in a good shape

This was an example of a bad dependency graph. Lazy loading was utilized, but it didn't decrease loading time as it could, because the browser had to download and parse a lot of files. But how can we avoid such situations? Honestly, considering how many files there can be in a real project, imagining a real dependency graph won't be helpful. But we can formalize some rules to improve it.

Rule #1: Embrace the Default

Did you notice how "Page1.tsx" imported something from "Page2.tsx", just like in the example from earlier? To display the former, the browser must also download the latter and all of its dependencies. And to avoid it, we should follow this rule:

📌 Each dynamically imported file must have only 1 exported entity, e.g. only default export.

If we followed this rule, we wouldn't be able to import anything else from "Page2" other than the code for displaying this page. And therefore, we wouldn't create this extra static dependency

Rule #2: Decentralize and Avoid Bad Patterns

Next, on graph you can see files which I called "facade". What are those? A facade file is a file which imports a bunch of stuff and exposes it by applying some logic or creating objects. In my particular case, facade files were used to store render methods for dozens of components in a single object:

import { First } from './component-1/first';
import { Second } from './component-1/second';
import { Third } from './component-1/third';
// other imports

export const renderComponentsGroup1 = {
  renderFirst: (prop1: string) => <First prop1={prop1} />,
  renderSecond: (prop2: string[]) => <Second prop2={prop2} />,
  renderThird: (prop3: number[]) => <Third prop3={prop3} />,
  // other dozens of methods
};
Enter fullscreen mode Exit fullscreen mode

"Facade" files may also provide utility methods, operate multiple classes, maybe even utilize diverse NPM libraries. And they are used way more often than you think. Basically, any Java-style design pattern like Factory, Orchestrator, Builder, Module, Service Layer, Repository, etc., - they all can be examples of "facade" files. And the problem with such files is that we should be extremely cautious about using them.

Let's say we take renderComponentsGroup1 from our example, and use only a single method renderFirst on Page1. Methods renderSecond and renderThird are entities of the same object, but we don't use them. Unfortunately, Webpack is not capable of splitting objects. Therefore, all the methods of this object will become dependencies of Page1. Moreover, all the dependencies - Second.tsx, Third.tsx, and other components which are used in this object, - will also become the dependency of Page1. And even if First.tsx is a tiny file itself, since we rendered it via renderComponentsGroup1, the browser must download Second.tsx, Third.tsx and all their dependencies regardless of how large they are.

And the problem here is not really with using objects, rather with centralizing your logic in a single file. Because the larger the file or its dependency tree, the worse the situation. There can be 2 scenarios of such centralization:

  1. a single file that contains thousands lines of code,
  2. or a relatively small file that reuses dozens of files, while each of those reuse their own files, and so on.

To deal with the first scenario we can simply follow this rule:

📌 Make your files as atomic as possible and export as few dependencies as possible.

Plus, I mentioned Java-style patterns because they may lead to the second scenario. Frankly, often developers may follow such patterns blindly, without actual purpose and only because "smart people said it's better this way". I'm not saying though that using them is essentially always bad. But for Front-End development they are not ideal. We should always ask ourselves twice whether using such a pattern really brings order to our codebase before applying them.

In my practice, using logic directly instead of wrapping it with patterns and interfaces may significantly improve your dependency graphs. Plus, usually having small files with independent function exports and sometimes re-export files is enough to cover 98% of needs in FE development.

📌 Try avoiding Java-style and other patterns that would centralize logic from several files into a single file.

Rule #3: Break It Down

As I already said, from a lazy-loading perspective, the ideal way to create a clean dependency tree is to make each source file to be as atomic as possible. Ideally, to export only 1 entity. Although, it's not really convenient DX-wise. So, I doubt any developer would want to create a separate file each time they want to have a shared constant or a utility method. So, to balance developer experience with efficient lazy loading, here's a simple guideline: don't export entities of different types from the same file.

You can still declare utility methods, constants, classes, and components in the same file. For example, we could store some utility methods and constants inside Chapter1.tsx. But when we need to export entities of different types, we have to create separate files: utility files for functions, constant files for constants, and so on. As for components, it's better to try to have as few of them as possible per file. And if any of components should become shared, we shouldn't be lazy to create a separate file for them.

Here's an example of how I split files in my project:

.

There's a simple logic behind such a splitting. A file which exports constants only is more likely to be re-used by other files, but less likely to have its own dependencies. Because usually constant files may import other constants but nothing else. Utility methods are second most likely to be re-used, and they usually only import constants and other utility methods. And well, for components it's just better to have as few of them per file as possible, because they will use any other files.

📌 Don't export entities of different types from a single file. Export utility methods only from dedicated utility files. Likewise for constants. As for components, try to have as few as possible per file.

Rule #4: Avoid the Loop

And another rule would be that we need to avoid cyclic dependencies in our application. It's actually a general rule to avoid them in front-end development for a variety of reasons. And one of them, is that if you have a cycle in your dependencies, Webpack will include every single file of this cycle, and their static dependencies as well.

.

The deeper the cycle goes, the more code will be included in the generated chunks. Or, alternatively, the more separate chunks will a browser download to display a lazy component.

📌 Avoid having cyclic import dependencies in your application.


Re-exports do not affect lazy loading. Unless...

Contrary to what I thought, using re-export files in your code doesn't affect lazy loading. Here's an example of such a file.

export * from './component-1/first';
export * from './component-1/second';
export * from './component-1/third';
Enter fullscreen mode Exit fullscreen mode

The difference between them and facade files is that the latter adds logic and wraps dependencies into objects, but a re-export file simply forwards imports without any logic. And I thought, using re-export files would have the same impact.

However, it doesn't. Webpack operates end declaration files when it analyzes dependency tree. If we do import an entity from a re-export file, Webpack will use the actual file where this entity was declared.

// So if you import an entity like this
import { First } from '@components';

// Webpack will treat it as
import { First } from '../../../components/component-1/first.tsx';
Enter fullscreen mode Exit fullscreen mode

Re-export files won't break our dependency tree even if you do barrel import. Because if we use barrel imports as regular imports, Webpack is still capable of building the correct dependency tree.

import * as components from '@components';

// Only `First` will be a dependency of this file
export default () => (
  <components.First {...} />
);
Enter fullscreen mode Exit fullscreen mode

But once we use barrel imports as a regular object, we are doomed. Such an object simply becomes a facade object, which we should avoid. And, with such an object, we end up downloading all the exports from the barrel import object.

In this particular example below, we tried to create a component which can display icons using their names. With such an approach, even if we use a single icon in our project, we'll end up downloading the entire package with all the icons. The same goes for our own source code re-export files.

import * as components from '@components';
import * as icons from 'react-icons/md';

// Using this component will lead to downloading all the icons
//  from react-icons/md, even if only 1 icon is used.
export Icon = ({ name }: { name: keyof typeof icons }) => {
  const IconElement = icons[name];
  return <IconElement />;
};

// Using this component will lead to downloading all the components
//  from @components, even if only 1 components is used.
export Component = ({ name }: { name: keyof typeof components }) => {
  const ComponentElement = components[name];
  return <ComponentElement />;
};
Enter fullscreen mode Exit fullscreen mode

📌 Try to avoid using barrel imports and especially using them as regular objects, unless it's really necessary.


Conclusion

Alright, that was long, but that's it for today. Thank you for joining me once again on our journey to make our web applications indefinitely lazy. If you have any questions feel free to ask them in comments.

And to summarize this article, let's list the rules we learned today:

  • 📌 Do not rely on tree-shaking. Split your files manually to ensure effective lazy loading.
  • 📌 Make each dynamically imported file have only 1 exported entity, e.g. only default export.
  • 📌 Make your files as atomic as possible and export as few dependencies as possible.
  • 📌 Try avoiding Java-style and other patterns that would centralize logic from several files into a single file.
  • 📌 Don't export entities of different types from a single file. Export utility methods only from dedicated utility files. Likewise for constants. As for components, try to have as few as possible per file.
  • 📌 Avoid having cyclic import dependencies in your application.
  • 📌 Try to avoid using barrel imports and especially using them as regular objects, unless it's really necessary.

We are only half way through. Stick around to dive even deeper:
How to make your app indefinitely lazy – Part 3: Vendors and Cache

Here are my social links: LinkedIn Telegram GitHub. See you ✌️

Top comments (0)