How to Reduce Next.js Bundle Size
How we analyzed and reduced 26.5% of the js payload of an e-commerce website build with Reactjs, Webpack & Next.js
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.
Let’s open our toolbox and see a few of the tools we can use to analyze js bundles and find scope for optimization.
Webpack Bundle Analyzer is a popular tool to analyze js bundles and here are a few of the key use cases.
- Analyze which components and libraries are part of a bundle.
- Discover if some library got included multiple times.
- Check if a library showing up unexpectedly in a bundle.
- 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.
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.
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
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.
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.
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) 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.
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.
To provide only necessary polyfills to browsers, Next.js has introduced the inbuilt module/nomodule pattern in v9.1.
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
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.
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.
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.
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.
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.
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 🎊🎊
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.
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.
Performance metrics for the desktop homepage after changes.
Performance metrics for the mobile-web homepage before changes.
Performance metrics for the mobile-web homepage after changes.
Please read the below article(a little self-promotion 🙈🙈) to find out how to set up a similar frontend performance dashboard.
Build Frontend Performance Monitor Dashboard Using PageSpeed Insights
How We Built our frontend performance monitor dashboard using PageSpeed, Apps Script, Google Sheet & Data Studio
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.
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.
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.
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.