Improving JavaScript Bundle Performance With Code-Splitting — Smashing Magazine

Improving JavaScript Bundle Performance With Code-Splitting

About The Author

Adrian Bece is a versatile fullstack web developer with extensive eCommerce experience who is currently working at PROTOTYP as a technical lead. He enjoys …

More about
Adrian ↬

Quick summary ↬

In this article, Adrian Bece shares more about the benefits and caveats of code-splitting and how page performance and load times can be improved by dynamically loading expensive, non-critical JavaScript bundles.

Projects built using JavaScript-based frameworks often ship large bundles of JavaScript that take time to download, parse and execute, blocking page render and user input in the process. This problem is more apparent on unreliable and slow networks and lower-end devices. In this article, we’re going to cover code-splitting best practices and showcase some examples using React, so we load the minimum JavaScript necessary to render a page and dynamically load sizeable non-critical bundles.

JavaScript-based frameworks like React made the process of developing web applications streamlined and efficient, for better or worse. This automatization often leads developers to treat a framework and build tools as a black box. It’s a common misconception that the code which is produced by the framework build tools (Webpack, for example) is fully optimized and cannot be improved upon any further.

Even though the final JavaScript bundles are tree-shaken and minified, usually the entire web application is contained within a single or just a few JavaScript files, depending on the project configuration and out-of-the-box framework features. What problem could there be if the file itself is minified and optimized?

Bundling Pitfalls

Let’s take a look at a simple example. The JavaScript bundle for our web app consists of the following six pages contained in individual components. Usually, those components consist of even more sub-components and other imports, but we’ll keep this simple for clarity.

When a user lands on a homepage, for example, the entire app.min.js bundle with code for other pages is loaded and parsed, which means that only a part of it is used and rendered on the page. This sounds inefficient, doesn’t it? In addition to that, all users are loading a restricted part of the app which only a few users will be able to have access to — the admin page. Even though the code is partially obfuscated as part of the minification process, we risk exposing API endpoints or other data reserved for admin users.

How can we make sure that user loads the bare minimum JavaScript needed to render the page they’re currently on? In addition to that, we also need to make sure that the bundles for restricted sections of the page are loaded by the authorized users only. The answer lies in code-splitting.

Before delving into details about code-splitting, let’s quickly remind ourselves what makes JavaScript so impactful on overall performance.

More after jump! Continue reading below ↓

Meet with useful tips on front-end, design & UX. Subscribe and get “Smart Interface Design Checklists” — a free PDF deck with 150+ questions to ask yourself when designing and building almost anything.

Once a week. Useful tips on front-end & UX. Trusted by 190.000 friendly folks.

Performance Costs

JavaScript’s effect on performance consists of download, parsing and the execution costs.

Like any file referenced and used on a website, it first needs to be downloaded from a server. How quickly the file is downloaded depends on the connection speed and the size of the file itself. Users can browse the Internet using slow and unreliable networks, so minification, optimization, and code-splitting of JavaScript files ensure that the user downloads the smallest file possible.

Unlike the image file, for example, which only needs to be rendered once the file has been downloaded, JavaScript files need to be parsed, compiled, and executed. This is a CPU-intensive operation that blocks the main thread making the page unresponsive for that time. A user cannot interact with the page during that phase even though the content might be displayed and has seemingly finished loading. If the script takes too long to parse and execute, the user will get the impression that the site is broken and leave. This is why Lighthouse and Core Web Vitals specify First Input Delay (FID) and Total Blocking Time (TBT) metrics to measure site interactivity and input responsiveness.

JavaScript is also a render-blocking resource, meaning that if the browser encounters a script within the HTML document which isn’t deferred, it doesn’t render the page until it loads and executes the script. HTML attributes async and defer signal to the browser not to block page processing, however, the CPU thread still gets blocked and the script needs to be executed before the page becomes responsive to user input.

Website performance is not consistent across devices. There is a wide range of devices available on the market with different CPU and memory specs, so it’s no surprise that the difference in JavaScript execution time between the high-end devices and average devices is huge.

To cater to a wide range of device specs and network types, we should ship only critical code. For JavaScript-based web apps, it means that only the code which is used on that particular page should be loaded, as loading the complete app bundle at once can result in longer execution times and, for users, longer waiting time until the page becomes usable and responsive to input.


With code-splitting, our goal is to defer the loading, parsing, and execution of JavaScript code which is not needed for the current page or state. For our example, that would mean that individual pages should be split into their respective bundles — homepage.min.js, login.min.js, dashboard.min.js, and so on.

When the user initially lands on the homepage, the main vendor bundle containing the framework and other shared dependencies should be loaded in alongside the bundle for the homepage. The user clicks on a button that toggles an account creation modal. As the user is interacting with the inputs, the expensive password strength check library is dynamically loaded. When a user creates an account and logs in successfully, they are redirected to the dashboard, and only then is the dashboard bundle loaded. It’s also important to note that this particular user doesn’t have an admin role on the web app, so the admin bundle is not loaded.

Dynamic Imports & Code-splitting In React

Code splitting is available out-of-the-box for Create React App and other frameworks that use Webpack like Gatsby and Next.js. If you have set up the React project manually or if you are using a framework that doesn’t have code-splitting configured out-of-the-box, you’ll have to consult the Webpack documentation or the documentation for the build tool that you’re using.

Before diving into code-splitting React components, we also need to mention that we can also code split functions in React by dynamically importing them. Dynamic importing is vanilla JavaScript, so this approach should work for all frameworks. However, keep in mind that this syntax is not supported by legacy browsers like Internet Explorer and Opera Mini.

In the following example, we have a blog post with a comment section. We’d like to encourage our readers to create an account and leave comments, so we are offering a quick way to create an account and start commenting by displaying the form next to the comment section if they’re not logged in.

The form is using a sizeable 800kB library to check password strength which could prove problematic for performance, so it’s the right candidate for code splitting. This is the exact scenario I was dealing with last year and we managed to achieve a noticeable performance boost by code-splitting this library to a separate bundle and loading it dynamically.

Let’s see what the Comments.jsx component looks like.

We’re importing the zxcvbn library directly and it gets included in the main bundle as a result. The resulting minified bundle for our tiny blog post component is a whopping 442kB gzipped! React library and this blog post page barely reach 45kB gzipped, so we have slowed down the initial loading of this page considerably by instantly loading this password checking library.

We can reach the same conclusion by looking at the Webpack Bundle Analyzer output for the app. That narrow rectangle on the far right is our blog post component.

Password checking is not critical for page render. Its functionality is required only when the user interacts with the password input. So, let’s code-split zxcvbn into a separate bundle, dynamically import it and load it only when the password input value changes, i.e. when the user starts typing their password. We need to remove the import statement and add the dynamic import statement to the password onChange event handler function.

Let’s see how our app behaves now after we’ve moved the library to a dynamic import.

As we can see from the video, the initial page load is around 45kB which covers only framework dependencies and the blog post page components. This is the ideal case since users will be able to get the content much faster, especially the ones using slower network connections.

Once the user starts typing in the password input, we can see the bundle for the zxcvbn library appears in the network tab and the result of the function running is displayed below the input. Even though this process repeats on every keypress, the file is only requested once and it runs instantly once it becomes available.

We can also confirm that the library has been code-split into a separate bundle by checking Webpack Bundle Analyzer output.

Third-party React components

Code-splitting React components are simple for most cases and it consists of the following four steps:

Let’s take a look at another example. This time we’re building a date-picking component that has requirements that default HTML date input cannot meet. We have chosen react-calendar as the library that we’re going to use.

Let’s take a look at the DatePicker component. We can see that the Calendar component from the react-calendar package is being displayed conditionally when the user focuses on the date input element.

This is pretty much a standard way almost anyone would have created this app. Let’s run the Webpack Bundle Analyzer and see what the bundles look like.

Just like in the previous example, the entire app is loaded in a single JavaScript bundle and react-calendar takes a considerable portion of it. Let’s see if we can code split it.

The first thing we need to notice is that the Calendar popup is loaded conditionally, only when the showModal state is set. This makes the Calendar component a prime candidate for code-splitting.

Next, we need to check if Calendar is a default export. In our case, it is.

Let’s change the DatePicker component to lazy load the Calendar component.

First, we need to remove the import statement and replace it with lazy import statement. Next, we need to wrap the lazy-loaded component in a Suspense component and provide a fallback which is rendered until the lazy-loaded component becomes available.

It’s important to note that fallback is a required prop of the Suspense component. We can provide any valid React node as a fallback:

Let’s run Webpack Bundle Analyzer and confirm that the react-calendar has been successfully code-split from the main bundle.

Project components

We are not limited to third-party components or NPM packages. We can code-split virtually any component in our project. Let’s take the website routes, for example, and code-split individual page components into separate bundles. That way, we’ll always load only the main (shared) bundle and a component bundle needed for the page we’re currently on.

Our main App.jsx consists of a React router and three components that are loaded depending on the current location (URL).

Each of those page components has a default export and is currently imported in a default non-lazy way for this example.

As we’ve already concluded, these components get included in the main bundle by default (depending on the framework and build tools) meaning that everything gets loaded regardless of the route which user lands on. Both Dashboard and About components are loaded on the homepage route and so on.

Let’s refactor our import statements like in the previous example and use lazy import to code-split page components. We also need to nest these components under a single Suspense component. If we had to provide a different fallback element for these components, we’d nest each component under a separate Suspense component. Components have a default export, so we don’t need to change them.

And that’s it! Page components are neatly split into separate packages and are loaded on-demand as the user navigates between the pages. Keep in mind, that you can provide a fallback component like a spinner or a skeleton loader to provide a better loading experience on slower networks and average to low-end devices.

What Should We Code-split?

It’s crucial to understand, which functions and components should be code-split into separate bundles from the get-go. That way, we can code-split proactively and early on in development and avoid the aforementioned bundling pitfalls and having to untangle everything.

You might already have some idea on how to choose the right components for code-splitting from the examples that we’ve covered. Here is a good baseline criterion to follow when choosing potential candidates for code-splitting:

We shouldn’t get overzealous with code-splitting. Although we identified potential candidates for code-splitting, we want to dynamically load bundles that significantly impact performance or load times. We want to avoid creating bundles with the size of a few hundred bytes or a few kilobytes. These micro-bundles can actually harm the UX and performance in some cases, as we’ll see later on in the article.

Auditing And Refactoring JavaScript Bundles

Some projects will require optimization later in the development cycle or even sometime after the project goes live. The main downside of code-splitting later in the development cycle is that you’ll have to deal with components and changes on a wider scale. If some widely-used component turns out a good candidate for code-splitting and it is used across 50 other components, the scope of the pull request and changes would be large and difficult to test if no automated test exists.

Being tasked with optimizing the performance of the entire web app may be a bit overwhelming at first. A good place to start is to audit the app using Webpack Bundle Analyzer or Source Map Explorer and identify bundles that should be code-split and fit the aforementioned criteria. An additional way of identifying those bundles is to run a performance test in a browser or use WebPageTest, and check which bundles block the CPU main thread the longest.

After identifying code-splitting candidates, we need to check the scope of changes that are required to code-split this component from the main bundle. At this point, we need to evaluate if the benefit of code-splitting outweighs the scope of changes required and the development and testing time investment. This risk is minimal to none early in the development cycle.

Finally, we need to verify that the component has been code-split correctly and that the main bundle size has decreased. We also need to build and test the component to avoid introducing potential issues.

There are a lot of steps for code-splitting a single existing component, so let’s summarize the steps in a quick checklist:

Performance Budgets

We can configure our build tools and continuous integration (CI) tools to catch bundle sizing issues early in development by setting performance budgets that can serve as a performance baseline or a general asset size limit. Build tools like Webpack, CI tools, and performance audit tools like Lighthouse can use the defined performance budgets and throw a warning if some bundle or resource goes over the budget limit. We can then run code-splitting for bundles that get caught by the performance budget monitor. This is especially useful information for pull request reviews, as we check how the added features affect the overall bundle size.

We can fine-tune performance budgets to tailor for worse possible user scenarios, and use that as a baseline for performance optimization. For example, if we use the scenario of a user browsing the site on an unreliable and slow connection on an average phone with a slower CPU as a baseline, we can provide optimal user experience for a much wider range of user devices and network types.

Alex Russell has covered this topic in great detail in his article on the topic of real-world web performance budgets and found out that the optimal budget size for those worst-case scenarios lies somewhere between 130kB and 170kB.

React Suspense And Server-Side Rendering (SSR)

An important caveat that we have to be aware of is that React Suspense component is only for client-side use, meaning that server-side rendering (SSR) will throw an error if it tries to render the Suspense component regardless of the fallback component. This issue will be addressed in the upcoming React version 18. However, if you are working on a project running on an older version of React, you will need to address this issue.

One way to address it is to check if the code is running on the browser which is a simple solution, if not a bit hacky.

However, this solution is far from perfect. The content won’t be rendered server-side which is perfectly fine for modals and other non-essential content. Usually, when we use SSR, it is for improved performance and SEO, so we want content-rich components to render into HTML, thus crawlers can parse them to improve search result rankings.

Until React version 18 is released, React team recommends using the Loadable Components library for this exact case. This plugin extends React’s lazy import and Suspense components, and adds Server-side rendering support, dynamic imports with dynamic properties, custom timeouts, and more. Loadable Components library is a great solution for larger and more complex React apps, and the basic React code-splitting is perfect for smaller and some medium apps.

Benefits And Caveats Of Code-Splitting

We’ve seen how page performance and load times can be improved by dynamically loading expensive, non-critical JavaScript bundles. As an added benefit of code-splitting, each JavaScript bundle gets its unique hash which means that when the app gets updated, the user’s browser will download only the updated bundles that have different hashes.

However, code-splitting can be easily abused and developers can get overzealous and create too many micro bundles which harm usability and performance. Dynamically loading too many smaller and irrelevant components can make the UI feel unresponsive and delayed, harming the overall user experience. Overzealous code-splitting can even harm performance in cases where the bundles are served via HTTP 1.1 which lacks multiplexing.


This content was originally published here.