The mechanics of Angular code-splitting and shared components in lazy-loaded modules

If the Angular CLI build output feels intimidating, or you're simply curious about how the underlying code-splitting process works, this article is for you.

Code-splitting breaks your application into multiple bundles that can be fetched as needed. Done well, this technique can dramatically improve initial load times.

Table of contents

  1. Why does this matter?
  2. How Angular CLI splits code internally
  3. A basic Angular app with lazy modules
  4. Sharing components across lazy modules
  5. Final thoughts

Why does this matter?

Say you've just scaffolded a fresh Angular project. You've read plenty of guides on structuring your app, arranging folders, and — most critically — maintaining strong startup performance.

You chose Angular CLI and built a modular application packed with lazy-loaded feature modules. Naturally, you also set up a shared module for frequently used pipes, directives, and components.

Over time, you realize that when a new feature module needs something from another feature module, you tend to move that code into the shared module.

Your app keeps evolving, and eventually, startup time starts violating expectations — yours and, more importantly, your client's.

Now doubts creep in…

  • If I place every pipe, directive, and component into one large shared module, then import that module in lazy-loaded modules (where only a couple of the exported features are used), won't I end up with duplicated, unused code in the build output?
  • Conversely, if I split shared utilities across multiple shared modules and only import what each lazy module actually needs, does that shrink the final bundle size? Or does Angular already handle such optimizations out of the box?

Let's clear up the confusion.

What Angular CLI does behind the scenes

As everyone knows, the current Angular CLI relies on webpack for bundling. But webpack is also the engine responsible for code-splitting.

So let’s inspect how webpack accomplishes this.

Webpack 4 introduced SplitChunksPlugin, which lets you define rules for carving modules into chunks. Many developers find this configuration cryptic, yet it’s the most fascinating part of code-splitting.

However, before SplitChunksPlugin optimization kicks in, webpack already creates a chunk:

  • for each entry point

Angular CLI defines these entry points:
main
polyfills
styles

These become chunks with identical names.

Remember the loadChildren syntax? That is the cue for webpack to spawn a chunk.


Now let’s examine SplitChunksPlugin. It lives inside the optimization block of webpack.config.js.

Let’s check the Angular CLI source code and locate that configuration section:

Code-splitting in Angular or how to share components between lazy modules — figure 1

SplitChunksPlugin configuration in Angular CLI 8

We’ll concentrate on the cacheGroups option here — this is webpack’s “recipe” for producing separate chunks based on certain conditions.

cacheGroups is a simple object where the key is a group name. Conceptually, each cache group represents a candidate for creating a new chunk.

Each group supports many settings and can inherit options from the splitChunks level.

Let’s quickly review the options we saw in the Angular CLI configuration above:

  • chunks filters modules between synchronous and asynchronous chunks. Possible values are initial, async, or all. The initial value means modules are only included if they are imported inside synchronous chunks. The async value means modules are only included when imported inside asynchronous chunks (this is the default).
  • minChunks instructs webpack to include modules in a chunk only when they’re shared between at least two chunks (the default is 1).
  • name tells webpack what to call the new chunk. If you specify a string or a function that always returns the same string, all shared modules get merged into a single chunk.
  • priority determines which chunk wins when a module belongs to multiple chunk groups.
  • enforce tells webpack to bypass minSize, minChunks, maxAsyncRequests, and maxInitialRequests options, always creating chunks for this cache group. One nuance: if any of those bypassed options appear at the cacheGroup level, that option will still be honored.
  • test controls which modules this cache group selects. As we saw, Angular CLI uses this to move all node_modules dependencies into a dedicated vendor chunk.
  • minSize defines the smallest allowed chunk size, in bytes, for issuing a new chunk. It wasn’t in the Angular CLI configuration, but it’s an important option you should know. (According to the source code, the default is 30kb in production and 10kb in development.)

Tip: although the webpack documentation lists defaults, you should check the webpack source to get the precise values.

Here’s the summary: Angular CLI routes a module to:

  • the vendor chunk if it originates from the node_modules folder.
  • a default chunk if that module is imported inside an async module and shared between at least two modules. Remember that several default chunks may appear simultaneously. Later I’ll explain how webpack builds names for these chunks.
  • the common chunk if that module is imported inside an async module and shared between at least two modules while missing out on the default chunk (thanks to priority) and regardless of chunk size (thanks to the enforce option).

Enough theory — time to try it out.

A basic Angular app with lazy modules

To see how SplitChunksPlugin operates, we’ll build a simplified Angular application:

app
├── a(lazy)
│   └── a.component.ts
│   └── a.module.ts           

├── ab
│   └── ab.component.ts
│   └── ab.module.ts

├── b(lazy)
│   └── b.component.ts
│   └── b.module.ts

└── c(lazy)
│   └── c.component.ts
│   └── c.module.ts

└── cd
│   └── cd.component.ts
│   └── cd.module.ts

└── d(lazy)
│   └── d.component.ts
│   └── d.module.ts

└── shared
│   └── shared.module.ts

└── app.component.ts
└── app.module.ts

Here a, b, c, and d are lazy modules, imported via the import() syntax.

Modules a and b use ab in their templates. Modules c and d use cd.

Code-splitting in Angular or how to share components between lazy modules — figure 2

Dependencies between Angular modules

The key difference is that ab.module gets imported by both a.module and b.module, while cd.module gets imported by shared.module.

This setup mirrors the exact scenario we’re trying to demystify. Let’s figure out where ab and cd end up in the final output.

The algorithm

  1. SplitChunksPlugin begins by assigning an index to each previously generated chunk.

Code-splitting in Angular or how to share components between lazy modules — figure 3

chunks by index

  1. Next, it iterates over every module in the compilation to populate the chunkSetsInGraph Map. This dictionary tracks which chunks contain the same code.

Code-splitting in Angular or how to share components between lazy modules — figure 4

chunkSetsInGraph

For instance, the row 1,2 main,polyfill indicates there’s at least one module present in both the main and polyfill chunks.

Because a and b rely on ab-module, the combination (4,5) also appears above.

  1. It goes through every module to determine if a new chunk can be formed for a given cacheGroup.

3a) First, webpack checks whether a module qualifies for a specific cacheGroup using the cacheGroup.test property.

ab.module tests

default test undefined => ok
common test undefined => ok
vendor test function => false

Neither the default nor the common cache group defines a test property, so the module passes. The vendor cache group, however, uses a function that filters modules from the node_modules path.

cd.module undergoes the same checks.

3b) Next, webpack loops through all possible chunk combinations.

Each module knows which chunks it belongs to (via the module.chunksIterable property).

ab.module appears in two lazy chunks. That gives combinations (4), (5), and (4,5).

Meanwhile, cd.module lives only in the shared module, i.e., only the main chunk. So its sole combination is (1).

The plugin then filters combinations using the minChunk size:

if (chunkCombination.size < cacheGroup.minChunks) continue;

Since ab.module has the (4,5) combination, it passes this check. The same can’t be said for cd.module — so it stays inside the main chunk.

3c) One more filter applies: cacheGroup.chunks (initial, async, or all).

ab.module appears inside async (lazy) chunks, which is exactly what the default and common cache groups require. As a result, ab.module gets added to two potential new chunks (default and common).

I promised earlier, so here it is.

How webpack chooses a name for a SplitChunksPlugin chunk

A simplified version of this process looks like:

Code-splitting in Angular or how to share components between lazy modules — figure 5

where:

  • groupName is the cache group name (default in our case)
  • ~ serves as the defaultAutomaticNameDelimiter
  • chunkNames is the list of all chunk names in that set. It resembles a fullPath, but uses a dash rather than a slash.

Code-splitting in Angular or how to share components between lazy modules — figure 6

For example, d-d-module means there's a d.module file inside the d folder.

Given our earlier usage of import('./a/a.module') and import('./b/b.module'), we get:

Code-splitting in Angular or how to share components between lazy modules — figure 7

A relevant detail: once a chunk name hits 109 characters, webpack truncates it and appends a hash at the end.

Code-splitting in Angular or how to share components between lazy modules — figure 8

Structure of a long chunk name spanning multiple lazy modules

We now have enough to fill chunksInfoMap — a map that records every candidate new chunk, the modules it should contain, and the current chunks holding those modules.

Code-splitting in Angular or how to share components between lazy modules — figure 9

chunksInfoMap

Filtering the chunk candidates

SplitChunksPlugin walks through chunksInfoMap entries to identify the best match. What does that mean exactly?

The default cache group carries a priority of 10, which beats common (whose priority is 5). So default comes first.

Once other requirements are satisfied, webpack strips that chunk’s modules from every other entry in chunksInfoMap. If a candidate ends up with zero modules, it gets discarded.

Consequently, default~a-a-module~b-b-module takes precedence over the common chunk, which is removed because it contains the exact same modules.

The final step involves minor optimizations (like deduplication) and verifying constraints such as maxSize.

The full source of SplitChunksPlugin is available here.


We’ve seen that webpack generates chunks in three ways:

  • for each entry point
  • for dynamically loaded modules
  • for shared code via SplitChunksPlugin

Code-splitting in Angular or how to share components between lazy modules — figure 10

Angular CLI output classified by chunk type

Now let’s revisit our original concern about the optimal way to handle shared code.

Sharing components across lazy modules

In our small Angular app, webpack allocated a separate chunk for ab.module yet bundled cd.module into the main chunk.

Here are the crucial takeaways:

  • If you place every shared pipe, directive, and component into one giant shared module and import it everywhere — including sync and async chunks — all that code ends up in the initial main chunk. If slow initial load time is your goal, that approach works.
  • If you split commonly used code across lazy-loaded modules instead, webpack will create a separate shared chunk that is only fetched when one of those lazy modules loads. This typically speeds up the initial load. Use this wisely, though — sometimes putting small code into one chunk beats the extra network round-trip for another request.

Code-splitting in Angular or how to share components between lazy modules — figure 11

Conclusion

Hopefully, you now have a clearer grasp of Angular CLI’s output and can tell apart entry chunks, dynamic chunks, and chunks produced by SplitChunksPlugin.

Happy coding!