Web bundlers such as Webpack are a great tool to deploy web apps, but for more complex programs, bundle size can quickly become an issue. While the underlying problem and therefore solution will vary from site to site, there are several changes you can implement to ensure Webpack and other bundlers are able to do as good a job as possible. In some cases, lint rules and other measures to prevent the same problems popping up again can even be utilised. These changes were mainly written with React, TypeScript, and Webpack 5 in mind. However, they generally apply to all applications across all bundlers, with possible syntax or naming changes.

How do web bundlers optimise bundles

Before we start improving things, it’s important to understand what is actually going on. Web bundlers such as webpack attempt to bundle your code and the various dependencies you use into single files and split them where possible and best appropriate. The process of splitting these is known as bundle splitting. Webpack also utilises a tool named Terser to minify and optimise code. Terser changes the names of variables and functions to be as small as possible, and performs a process known as tree shaking to remove any code that’s not used by the application.

Tree shaking is a complex process and must be done cautiously to prevent breaking functionality. If there’s even a chance that the code is impacting the app’s behaviour, Terser won’t remove it. There are also cases where the act of just importing a file causes something to happen, even without any imported functions being called. These are called side effects. Side effect detection is another complex process that these tools must do in order to determine if something is safe to ignore. If an imported file is unused but Terser cautiously decides that it might contain a side effect, it won’t be tree shaken. Due to the complexity of a dynamic language like JavaScript, side effect detection can and does easily produce false positives.

Older module standards such as CommonJS are also much less capable of being tree shaken, unlike newer standards such as ESModule (or ESM for short). Most bundlers will not even attempt to tree shake these module formats, or act substantially more cautiously if they do.

Measuring bundle size

When doing any optimisation project, it’s a good idea to first get some metrics. In this case, you should understand how large the bundles are and what they contain. Generally, the entry point bundles are the ones most worth looking at. Depending on application structure, this might have a name starting with main, the name of the page route, or the name of a template file.

If you’re using Webpack, the tool Webpack Bundle Analyzer can be invaluable for this. Running a webpack build with this plugin setup and enabled will generate an HTML file in your output directory, that lets you visualise each bundle and the various files and dependencies that they contain.

There are also tools that integrate into CI pipelines, such as Bundlewatch, however these are more useful for preventing further increases in bundle size than providing insights into any current issues.

Fixing bundle size issues

Non-ESModule dependencies

One of the most common causes of bundle size bloat are modules that can’t be tree-shaken, such as CommonJS modules. It can be a good idea to take note of the larger dependencies found through bundle analysis, and research if the package provides an ESM variant. A quick way to check for this is to see if a "module" entry is present inside the dependency’s package.json file in node_modules.

Once you’ve identified modules that aren’t available in the ESM format, it’s a good idea to check if an update is available that provides it. It’s worth checking the NPM page or GitHub, in case the dependency has undergone a change in NPM package name. For example, react-query recently became @tanstack/react-query, with the new package name including an ESM release. If no new update is available, checking GitHub issues might provide timeframes or discussions around this.

For these cases where no ESM update is available though, there are a few options. In most cases, it’s a matter of deciding whether the potential bundle size savings are worth the development cost of switching to a new dependency or contributing ESM support yourself. There are some projects, such as esm-bundle that provide re-compiles of packages in ESM format, however these can be troublesome to setup for modules that are frequently used as dependencies.

CommonJS Module type in TSConfig

If you’re using TypeScript alongside a bundler, an easy mistake to make that will disable many optimisations, is to have the "module" property of the TSConfig file set to CommonJS. This doesn’t set the actual output of the bundler, but instead changes what intermediary format the bundler receives the compiled TypeScript in. In most cases this should be easy to change to an ESM format, such as "es2020". If you’re using Jest, it might still need the CommonJS output, in which case you can setup a second tsconfig.spec.json file that extends the first but sets module back to CommonJS. This allows the bundler to receive ESM, while Jest can be set to use the altered TSConfig to retain CommonJS.

Marking code as side effect free

In some bundlers, such as Webpack, you can give hints to the side effect detection process. If you’re sure your code doesn’t contain side effects, you can set the "sideEffects" property in your package.json file to false. This tells the bundler that the library doesn’t contain side effects, allowing much more in-depth tree-shaking to take place. This effect can be much more pronounced when using libraries such as React, as various React patterns such as higher order components can falsely trigger side effect detection.

If you do have side effects, but know exactly where they are, you can instead pass a list of patterns to the "sideEffects" property. Specific syntax is outlined in the Webpack documentation. This allows marking a package as mostly side-effect free, while still retaining side effects in situations such as CSS imports.

Using webpack rules

For more complex cases, it’s also possible to set the sideEffects property via webpack rules. This can be useful to perform more dynamic marking, or to mark files from dependencies. An example rule is the following, which would mark all index.ts files as side-effect free.

rules: [
  {
    test: /\/index.ts$/,
    sideEffects: false,
  },
];

Type modifier on imports and exports

If you’re using TypeScript, you’ll often find that you’re importing a file purely to grab types from it. In some situations, this can actually cause that file to not be tree-shaken, despite types having no impact on the output code. If Webpack imports a file and deems it has side effects, even if the only import was a type, it will disable the “used exports” optimisation, and keep the file around anyway.

One way to resolve this is to use the type modifier on type imports and exports in your application. These are covered on the TypeScript documentation and are basically modifiers that can be placed after the import and export keywords to state that all named import/exports that follow are types and can be erased by the compiler. As these entire statements are erased, the bundler never sees the import or export, and therefore will not check the file for side effects. In newer TypeScript versions (4.5+), you can also mark individual imports as types rather than the whole statement.

Example,

// TS 3.8+ syntax
import type { A, B, C } from './letters';
// TS 4.5+ syntax
import { type D, E } from './moreLetters';
// TS 3.8+ syntax
export type { F, G } from './letters';
// TS 4.5+ syntax
export { type H, I } from './moreLetters';

Using linter rules to enforce

The @typescript-eslint/eslint-plugin package supplies two ESLint rules, both with auto-fixers, that can automatically apply these to your codebase. consistent-type-imports handles the type modifier on import statements, and consistent-type-exports handles the type modifier on export statements. This can be a great way to prevent these issues creeping back in to your codebase over time, and helps onboard new engineers to the process.

Barrels

Barrel files are a common pattern amongst JavaScript apps, but they can be an easy way to accidentally pull files into your bundle that you didn’t expect. Barrel files are files that re-export the exports from other files. Usually using a wildcard export, such as the following,

export * from './someFile';
export * from './someOtherFile';

While this can be convenient to keep imports tidy, if one of the files getting re-exported contains a side effect, it will be bundled. This will even happen if the import you’re using wasn’t even in the file that got re-exported.

One way to resolve this is to name all barrel files something easily identifiable, such as index.js or index.ts. Then a Webpack rule such as the one earlier in the article can be used to disable side effect detection for these. Another method to reduce this for TypeScript users would be to use named exports rather than wildcard exports, as then type modifiers can be applied to the exports. This would look like the following,

export type { A, B, C } from './someFile';
export { type D, E, F } from './someOtherFile';

This also provides a small improvement to correctness, given accidental duplicate exports can happen using wildcard exports in barrels. Explicitly exporting named exports also allows better encapsulation, by only exporting what you want exposed to other packages rather than exports used between internal files.

Lazy loading

Lazy loading has the potential to be the most impactful optimisation on this page, however I left it last for a reason. While lazy loading is a great tool, it will be held back by a codebase full of the other problems mentioned throughout this article. To get the most out of lazy-loading, your codebase needs to be easily analyzable by your bundler, and as tree-shakeable as possible.

Lazy loading can also cause more problems than solutions if not well thought out. Loading code to parse a file that the user is waiting on right as the process starts will likely lead to a worse user experience than loading it up-front. It’s important to think through where lazy loading will happen, to ensure that the app is split in a way that won’t impede on user experience but will also adequately split the bundles. If you previously had lazy loading setup before doing the above steps, it can also be important to ensure that those places still make sense to lazy load. It’s common for engineers to lazy load many small sections of a codebase to make Webpack split a bundle, only to find that it causes a large number of extremely small bundles to be created once the above problems are solved.

You can lazily-load JavaScript code using the dynamic import statement, import('./Module').then(module => {});. This can be best used at the router level for frontend applications. As this statement is a promise, you can either await it and use the response as any normal imported module or use the callback in .then() to access the imported module.

React Apps

In React Apps, you can use a mixture of React.lazy calls and Suspense components to easily setup lazy loading of components. The dynamic import call can be wrapped in a React.lazy, and then used as a normal component within a Suspense component. For example,

const LazyViewer = React.lazy(() => import('./Viewer'));

const App = () => (
  <Suspense fallback={<Spinner />}>
    <LazyViewer />
  </Suspense>
);

It’s important to note that these should ideally only be used in situations that user input is required to get to, otherwise it may cause poor user experience due to many loading states throughout the application. A great example for a location to have one of these would be inside a modal or dialog box, where the base component is loaded early but the React.lazy component doesn’t render until the modal/dialog is opened. More information on React.lazy can be found on the React docs.

Prefetching and Preloading

With webpack, you can provide hints to prefetch the lazy-loaded bundle after its parent bundle loads. This can be done by adding /* webpackPrefetch: true */ before the path in the dynamic import statement. If you want it to load alongside the parent bundle rather than after, you can also add /* webpackPreload: true */. Using preload rather than prefetch can be a bad idea if overused however, as it is downloaded with a moderate priority, rather than when the browser is idle. This can cause it to take precedence over other assets or bundles needed for the page load.

An example import using both a prefetch and preload is, import(/* webpackPrefetch: true */ /* webpackPreload: true */ './Module').then(module => {});. More information on these hints can be found on the Webpack documentation.

Conclusion

There are a huge number of things you can do to improve your bundle size in webapps. While this article might seem like it’s throwing a lot at you all at once, my goal is to help you understand how some of the common pitfalls happen so that you have a stronger understanding of what bundlers and optimisers are actually doing. While these suggestions will strongly help on most sites, the learnings about how tree-shaking can fail from this article should help you to find further improvements in your own projects.

I’ve successfully used these methods to heavily optimise this site, a few other personal project sites, and some projects at work. If I find something new or something in the JS ecosystem changes, I’ll make sure to update this article.

About the Author

Maddy Miller

Hi, I'm Maddy Miller, a Senior Software Engineer at Clipchamp at Microsoft. In my spare time I love writing articles, and I also develop the Minecraft mods WorldEdit, WorldGuard, and CraftBook. My opinions are my own and do not represent those of my employer in any capacity.