How to Reduce Next.js Bundle Size

How we analyzed and reduced 26.5% of the js payload of an e-commerce website built with Reactjs, Webpack & Next.js

Arijit Mondal
NE Digital
11 min readOct 5, 2020

--

Photo by chuttersnap on Unsplash

In NE Digital, we are continuously working to provide faster and smoother user experience irrespective of the internet connection or the device type. In order to achieve that shipping less amount of javascript payload is one of our key focus areas.

Byte-for-byte, JavaScript is still the most expensive resource we send to mobile phones, because it can delay interactivity in large ways. Addy Osmani

We are using WebPageTest to do our performance benchmark on the homepage. With device type as MotoG4 and connection type as 3GFast, we observed the following results.

main thread processing breakdown

From the above image, we can observe that around 76.4% of processing time is consumed by scripting, and among that around 35.5% of the time is taken by Evaluatescript alone.

If we can reduce the amount of js we ship, then those smaller scripts can be downloaded faster and it will reduce CPU execution time. As a result, the main thread will be able to respond to user interaction much earlier providing a much better user experience.

Tools to Analyze Javascript Bundles 🛠

Let’s open our toolbox and see a few of the tools we can use to analyze js bundles and find scope for optimization.

Toolbox Photo by Barn Images on Unsplash

1) Webpack Bundle Analyzer

Webpack Bundle Analyzer is a popular tool to analyze js bundles and here are a few of the key use cases.

  1. Analyze which components and libraries are part of a bundle.
  2. Discover if some library got included multiple times.
  3. Check if a library showing up unexpectedly in a bundle.
  4. Check if the tree shaking for a specific dependency library is working properly or not.

Here is a sample interactive treemap visualization generated using Webpack Bundle Analyzer.

Sample Webpack bundle analyzer visualization, image credit webpack-bundle-analyzer

We can easily add Webpack Bundle Analyzer in our next.config.js using the below code.

After that, we can generate the visualization using the below command

ANALYZE=true npm run build

ANALYZE flag ensures that Webpack Bundle Analyzer runs only for those builds only when the flag is set to true.

2) Source Map Explorer

Source-map-explorer is a useful tool for analyzing each bundle and finding any bloat in it. Once the npm package is installed, we can run the below command to generate a treemap visualization as shown in the below image.

source-map-explorer bundle.min.js bundle.min.js.map
Sample Source Map Explorer visualization, Image Credit Source-Map-Explorer

3) Bundle Wizard

Bundle Wizard is a key tool to get an overview of all bundles loaded for a specific page. The best part about bundle-wizard is that it color-codes coverage for all components.

npx bundle-wizard https://fairprice.com.sg

Running this command will generate a color-coded visualization similar to as shown below. From this, we can easily analyze which component/library in a bundle has less coverage and needs further investigation for optimizing the bundle.

js bundles visualization of Fairprice home page using Bundle Wizard

4) Chrome DevTools Coverage

We can type CTRL+SHIFT+P in Windows or CMD+SHIFT+P in Mac inside Chrome DevTools and then select show coverage drawer to open it. Once we click on the reload button, it reloads all files and shows coverage for all js files. We can filter by URL to show coverage for 1st Party js files.

Once we click on a file inside the coverage drawer, it will be opened in the Sources panel and line numbers highlighted in red indicates parts of the code in a js bundle are not executed. Below we can see how to discover unused code using the Chrome DevTools coverage drawer.

Let’s Reduce Javascript Bundle Size 🚀🚀

let’s reduce js bundle size, image credit https://giphy.com/

After using the above-mentioned tools to analyze our js bundles, we discovered parts of code that can be loaded on-demand or don’t need to be loaded for all browsers. Let’s see some of the key changes we made to reduce our js size.

1) Improve Polyfill

With every new version of js, we get many useful functionalities that make developing features easier. But the adoption of these new features takes time for different browsers. Polyfills let us write the latest js functionality without waiting for it to be available natively in all browsers.

A polyfill is a piece of code (usually JavaScript on the Web) used to provide modern functionality on older browsers that do not natively support it. — MDN

a) Remove explicit core.js import

Our Application supports IE11 along with other commonly used desktop and mobile browsers. So even though we write code using newer features of js, we still need to provide polyfills for older browsers. To resolve this, earlier we had explicitly imported core-js in our code as shown below.

import 'core-js'

Because of this, polyfills were being served even to the browsers supporting given features of js and therefore those browsers were unnecessary getting huge amounts of polyfills.

From the below Webpack Bundle Analyzer visualization, we can see that roughly 30.83kb gzipped js footprint was explicitly added by explicit core.js import.

explicit core-js import adding 30.8kb gzipped footprint to bundle size

To provide only necessary polyfills to browsers, Next.js has introduced the inbuilt module/nomodule pattern in v9.1.

The module/nomodule pattern provides a reliable mechanism for serving modern JavaScript to modern browsers while allowing older browsers to fall back to polyfilled ES5 code.

After migrating to Next.js v9.1, we took advantage of the inbuilt module/nomodule pattern and removed our explicit import of core-js without facing any issues in IE 11 or other older browsers.

b) Use feature detection to load polyfill

core-js supports ECMAScript and closely related ECMAScript features. There are some browsers only features such as IntersectionObserver that core-js currently does not support.

For those cases, we use feature detection and load polyfills only in the browsers that don’t support the feature. Below we can see how to load intersection-observer polyfill using feature-detection.

2) Use dynamic import

Next.js comes preconfigured with great code-splitting strategy and it has been further improved since v 9.2. It already does route based code splitting. So when we load a page, only js chunks specific to that page is loaded.

On top of that Next.js also provides next/dynamic to let us implement component-based code splitting. For our website, we have lots of popups that get triggered by user action. We decided to use next/dynamic to load all such popups on-demand to remove those from the initial payload. Below we can see that after clicking on the search input box search suggestion chunk is dynamically loaded and the search suggestion popup is shown to users.

We also have some components which only load when some specific condition is fulfilled. We have also decided to use the next/dynamic for those cases. Some sample code of this pattern can be found in the Next.js Github repo examples.

Milica Mihajlija has written an excellent in-depth article on this topic which I highly recommend reading.

3) Remove duplicate packages

As our project grows and we keep adding several npm packages, there are possibilities of several different versions of the same package getting included in our Webpack bundles.

To find out if this is happening with our project we are going to use duplicate-package-checker-webpack-plugin. Next.js makes it very easy and straightforward to add custom Webpack config.

After setup, once we generate build, we can see a result similar to the below one. Here we have fast-deep-equal package included twice in our bundle since we are directly using the 2.0.1 version of it and react-image-magnify is also using v1.0.0 of it as a sub dependency.

duplicate package in js bundle found using duplicate-package-checker-webpack-plugin

There are several ways to solve this issue. Depending on our tool (npm or yarn) and the specific library we can take different approaches.

Below we are going to use resolve.alias feature of Webpack to resolve duplicate fast-deep-equal npm package issues in the bundle.

Word of caution ⏰ ⏰

Some libraries introduce breaking changes between the major versions, so before resolving to a higher version, it should be checked if any feature is breaking.

4) Load library based on user interaction

There are few scenarios where we can avoid loading an npm package at the initial load time and trigger it on-demand based on user interaction. Let's see an example of the above pattern.

On our website, we have a feature called ScrollToTop where users can click a specific icon to scroll to the top of the page. We are using react-scroll package to implement this feature.

But unless the user clicks on the icon, we don’t need to load the library upfront. So in this case, when the user clicks on the ScrollToTop icon we import the react-scroll library inside our handleScrollToTop function, and then it scrolls the user to the top of the page.

From the below image it can be seen that when the user clicks on ScrollToTop icon, the additional js chunk is loaded on demand, and the user is scrolled to the top of the page.

on-demand library load based on the user interaction

5) Webpack Specific Optimization

Few of the libraries can be optimized using different webpack plugins. We have found many useful Webpack specific optimizations from this GitHub repo maintained by Ivan Akulov. One of the great things about the repo is that it clearly mentions which changes are safe to make and which ones we need to be cautious about.

For libraries like lodash and date-fn, we can easily reduce bundle size by just importing specific functions instead of full import.

Update[9th Oct, 21]: Now importing from named export { isPast } size the same when importing from specific file like date-fns/is_past. Thank @ongkiherlambang for pointing this.

6) Remove inline big images from js chunks

We are using next-optimized-images for the build-time optimization of our images. While analyzing our bundles via webpack-bundle-analyzer we discovered many images were getting inlined 😭😭 in our js chunk increasing the overall bundle size.

Upon further investigation, we found that next-optimized-images have a config option inlineImageLimit which has a default value of 8192 bytes making all images below this size inlined with a data-uri by url-loader.

For our use-case, we wanted to disable this image inlining feature. To disable this we set inlineImageLimit to -1. After making this change, we reran the webpack-bundle-analyzer again and verified that images are not getting inlined anymore.

Let’s See The Results 🎊🎊

let's see results, Image credit https://giphy.com/

After implementing the patterns mentioned above, these are the significant changes we noticed from our frontend performance dashboard. The below results are based on data collected from the PageSpeed Insights API.

js size before and after the changes

Our overall js size(brotli compressed) reduced from 923kb ==> 831 kb(~10% reduction) and our first-party js size reduced from 347kb ==> 255kb(~26.5% reduction).

Along with js size reduction, we also noticed improvement across different key performance metrics.

Performance metrics for the desktop homepage before changes.

homepage metrics for desktop before changes

Performance metrics for the desktop homepage after changes.

homepage metrics for desktop after changes

Performance metrics for the mobile-web homepage before changes.

homepage performance metrics for mobile before changes

Performance metrics for the mobile-web homepage after changes.

homepage performance metrics for mobile after changes

Please read the below article(a little self-promotion 🙈🙈) to find out how to set up a similar frontend performance dashboard.

Bonus Tip

These are the tools we often use while adding a new npm package or finding out how to import a package optimally in our code.

Bundle Phobia

Whenever we need to add an npm package to our project we always check the import cost of that package using Bundle Phobia. It gives several key details about the package such as minified and gzipped bundle size, download time, and the composition of the package.

Bundle phobia import cost view

A cool feature of bundle phobia is it also recommends several similar packages with a lower footprint. The image below shows several similar package suggestions for momentjs.

Similar package suggestion of bundle phobia

Import Cost Extension

This editor extension is available for both Sublime Text and VSCode. We found this extension useful since it shows the import cost upfront at the time of adding an import statement in our code. This helps us to prevent accidental full imports and avoid importing with a larger footprint.

Below we can see an example where import cost extension is showing that instead of importing from date-fns, if we only import the specific function of date-fns library, we can reduce the js payload.

size when import from date-fn library
size when imported only the specific function of the date-fns library

Final Thoughts

Many of the patterns shared above can also be applied to sites built with Angularjs or Vuejs. We are continuously monitoring and looking for scopes to reduce our javascript payload even further.

In the next article, I have shared how we improved the LCP and Speed Index and reduced the image payload of our website.

--

--