notes.dt.in.th

Analyzing Next.js bundle size

When building a production-grade Next.js app,** you may eventually run into problems with large bundle size:

Easy case — direct dependencies

The first thing to do is to use @next/bundle-analyzer to analyze the bundle size.

This can generate a report that looks like this:

In this case, it is obvious what is going on. We are importing the whole Font Awesome library, which is huge. We can easily optimize this by importing just the icons we need.

-import { far } from '@fortawesome/pro-regular-svg-icons'
+import { faEnvelope } from '@fortawesome/pro-regular-svg-icons/faEnvelope'

Complicated case — transitive dependencies

This is where official help from Next.js framework ended.

But things can still get more complicated when the bundle size is large, not because of direct dependencies, but because of dependencies of dependencies (transitive dependencies).

In the example above, it seems like an HTML5 parser and the cheerio library is included in the bundle. But why? We are not doing any HTML parsing in our app, so it must be one of our dependencies that we use that pulls in these libraries.

The @next/bundle-analyzer plugin shows the size of each module, but it does not tell us why that module is included in the bundle in the first place. To find that out, we need more information about the build process.

Generating a stats file

Under the hood, @next/bundle-analyzer uses webpack-bundle-analyzer to generate the report. We can use that to generate a stats file that shows the reasons why each module is included in the bundle by passing the generateStatsFile option.

However, @next/bundle-analyzer has hardcoded the options passed to webpack-bundle-analyzer, so turning that option on requires some hackery.

Edit node_modules/@next/bundle-analyzer/index.js and add the generateStatsFile option to the webpack-bundle-analyzer options:

           config.plugins.push(
             new BundleAnalyzerPlugin({
+              generateStatsFile: true,
               analyzerMode: 'static',
               openAnalyzer,
               reportFilename: options.isServer
                 ? '../analyze/server.html'
                 : './analyze/client.html',
             })
           )

Build the app again. This time, the console should now show the path to the stats file:

Webpack Bundle Analyzer saved report to /.../.next/analyze/client.html
Webpack Bundle Analyzer saved stats file to /.../.next/stats.json

Exploring the stats file

The stats file is a JSON file that contains a lot of information about the build process. Fortunately, the webpack project provides the analyze tool to help us explore it.

When you open the stats file in the analyze tool, and click on the Modules tab.

You will see module graph, and a table of modules.

Finding the culprit

Search for the module you want to find. In this case, we are looking for cheerio. Once you find it, click on the module ID to inspect it. Also take note of the ID, it will become useful later (in this case, 848).

The module graph will update to highlight the selected module.

Scroll your mouse wheel to zoom in and out.

  • The module being inspected is highlighted in black.
  • The modules that depend on the module (‘reasons’) are highlighted in red.
  • The dependencies of the module are highlighted in green.

The same information can be seen below the graph in the reasons and dependencies sections.

Keep following the reasons until you find the culprit in your code.

In our case,

  • We have a module ./src/utils/Helper.ts which is a collection of helper functions.
  • One of the helper function imports juice, which imports cheerio, which imports htmlparser2. This helper function is only used in the server-side code.
  • However, many client-side modules also imports ./src/utils/Helper.ts, which caused juice, along with its dependencies, to be included in the client-side bundle.

So the solution to optimize the bundle size in this scenario is to separate the Helpers module into two, one for the server and one for the client.