Bundle Splitting

Labeeb Latheef

By Labeeb Latheef

on January 30, 2024

Loading JavaScript assets faster by bundling

When we write code, we split parts of code in different files to manage complexity. However the web application requires all of its JavaScript files in order for the application to function properly. On the surface it might seem like loading all the separate JavaScript files is easy but it gets very complicated very fast.

Let's assume that we have three different JavaScript files in our codebase that we are referencing directly from the HTML.

2  <script src="/assets/js/script_1.js"></script>
3  <script src="/assets/js/script_2.js"></script>
4  <script src="/assets/js/script_3.js"></script>

Now what if we find that script_1.js has a dependency on script_2.js and script_2.js needs to be loaded first? We will have to rearrange the ordering such that all the dependencies are satisfied. Doing this iteratively for hundreds of files can quickly become a nightmare.

One could argue why can't we write all the code in a single JavaScript file in order to avoid this dependency issue. Modern web applications require a lot of JavaScript code so having all the JavaScript code in a single file is just not practical.

Another issue with referencing hundreds of JavaScript files is that browsers can fetch only four to eight files at a time. If we have hundreds of JavaScript files then the browser will take a long time to load all the JavaScript files.

Solution to all these problems is packing all those JavaScript files in one large file that can be downloaded in one shot. This large file will also have all the code in the right order which will satisfy the dependency requirement. This process of packing all the JavaScript files in one big file is called bundling. We can use tools like Webpack or Rollup to achieve bundling. These tools that help us achieve bundling are typically called bundlers.

Single bundle

It's obvious that when use a bundler then it allows us to keep our code in any hierarchy we want for the development purpose. After all the bundler will figure out the dependency. So is the job of bundler to only stitch all the JavaScript files in one large JavaScript file in the right order?

No. During bundling, a bundler can be configured to apply different transformations and optimizations on our source code. It allows us to write our code using modern syntax while transforming it to a format more commonly supported by different browser implementations during the bundling process. Another common task is the minification of our JavaScript files by removing all unwanted whitespace characters and comments.

We have discussed only a few optimizations and transformations here. Bundlers support many more types of optimizations and transformations.

Sample bundler output

Loading JavaScript files faster by bundle splitting

Bundle splitting is the process of splitting up this single bundle into multiple chunks. We just talked about the advantages of bundling. Then why are we talking about bundle splitting. As we will see there are some advantages of splitting the bundle as long as it's done strategecially.

Most commondly bundle splitting splits the code into three separate chunks. A vendor chunk holds every code inside the node_modules folder, an application chunk holds all the code we have written, and a runtime chunk is added by the bundler that enables all other chunks to be loaded properly. If we are using webpack to bundle our application, the default bundle splitting configuration can be found in their documentation. If we use CRA to scaffold our application, an optimal configuration is already in place.

There are different ways in which we can benefit from this process. Thanks to HTTP 2.0, the browsers are now able to fetch resources from the server in parallel. Because of this, instead of waiting for a single huge bundle to be loaded, the browser can load multiple smaller chunks in parallel for a better load time.

Alt text

On top of that, we can ask the browser to cache specific JavaScript file. This will make the application load those JavaScript files faster the next time around. Usually, the contents in the vendor chunk are not expected to change frequently and can be reused from the browser cache, cutting down on load time significantly. This is usually achieved by encoding the chunk name with the content hash. This tells the browser: "If hash changed, assume content changed, ignore the cache, and fetch updated chunk from server".

1// webpack.config.json
3module.exports = {
4  //...
5  output: {
6    filename: "[contenthash].js",
7  },

Now the question is what factors determine if the bundle should be split into multiple chunks. One such factor is the file location. As mentioned above, files from the node_modules folder are generally kept in the vendor chunk while the application code goes into the application chunk. We can also specify a maximum chunk size in the bundler configuration which creates an additional chunk whenever the current chunk exceeds the size limit.

1// webpack.config.json
3module.exports = {
4  //...
5  optimization: {
6    splitChunks: {
7      maxSize: 15000000, // 15MB in static size, before compression
8    },
9  },

We can take this a step further by loading only those chunks that are necessary to render the content that should be made visible to the user. Other chunks are loaded only when they are truly needed. This is also called lazy loading.

For example, suppose we have an application that offers two pages - a Dashboard page, and a Settings page. A user, after logging in, directly lands on the Dashboard page. Normally, the loaded JavaScript bundle will also include code related to the Settings page which is not required to render the current Dashboard page. The Settings code just sits there, waiting for the user to navigate to the Settings page.

All components in single bundle

This is the problem that lazy loading attempts to resolve. The easiest way to achieve this is to import the code during runtime using an inline import statement. React offers a wrapper for integrating lazy-loaded components using Suspense.

1import React, { Suspense } from "react";
3const LazyComponent = React.lazy(() => import("./LazyComponent"));
5const Component = () => (
6  <Suspense fallback={<div>Loading LazyComponent</div>}>
7    <LazyComponent />
8  </Suspense>

It means, during the bundling process, whenever a lazy import is encountered by the bundler, the code in the required module will be kept as a separate "async" chunk which is loaded only on demand. In the prior example, we can have the Settings component lazily imported so that when a user goes to the Dashboard page, the Settings-related code will not be part of this bundle resulting in a lower initial bundle size.

Components splits into different chunks

We can verify the results using tools like Bundle Analyzer Plugin that allow us to visualize and inspect the size and contents of each chunk in the bundler output.

The lazy loading above may seem very promising and we may be tempted to split every part of the application into different chunks expecting a more efficient loading experience. However, this may not always be the case. Unlike other methods, lazy loading requires careful evaluation of different parts of the application and their dependencies to determine which parts can be effectively lazy-loaded.

Let's bring back the above example. Suppose our Settings page requires API calls to load data and render the content. If the code for the Settings page is part of the same initial bundle, as soon as we navigate to the Settings page, the API requests are fired, data is fetched and content is rendered. Point to note that the JavaScript code for the Settings page is already loaded so the API call was instantly made.

Now what if the Settings code was split into a different on-demand chunk and not part of the initial bundle? No when we navigate to the Settings page, the browser needs to fetch the Settings chunk from the server, execute, send network requests, and then render the content. An additional delay is introduced here for fetching chunks which may add to the slowness. In a way, this brings down the benefits offered by a single-page application (SPA).

When splitting up the bundle for our product neetoChat, we chose to split the Settings related code into a separate, on-demand chunk. The Settings was not a random choice. It took up a large part of the bundle and one interesting feature was that the Settings page didn't require data from the server to render. In this case Settings page contains a predefined set of category tiles that allows us to navigate to other settings-related pages.

neetoChat Settings

For the reasons mentioned above, the Settings appeared to be a good candidate for lazy-loading and therefore, all the related code was extracted to a few separate asynchronous bundles. After this change, when a user loads the dashboard page of neetoChat, the loaded bundles do not contain code related to any of the Settings pages thereby having a smaller overall initial asset size.

Later, When the user navigates to the Settings page, the related JavaScript chunks are fetched and the UI is rendered immediately without the need for API data. From the Settings page, the user can choose any of the Settings categories to navigate to the inner pages. The inner pages require API data to render the content. However, the required JS assets are already loaded and available to execute as part of the last asset request on the landing page thereby reducing the network latency.

For more fine-grained control over lazy-loading implementation in our app, we are using the react-loadable library. Video below walks you through the bundle splitting process and results in the neetoChat web application.

Stay up to date with our blogs. Sign up for our newsletter.

We write about Ruby on Rails, ReactJS, React Native, remote work,open source, engineering & design.