Bundle analysis

Publish at:
analysis size-limit bundle-buddy lighthouse webpack-bundle-analyzer

Finally, it is time to analyze what we have accomplished so far. Webpack setups can be quite complex. That complexity can undermine the efforts put in place. If it cannot be eliminated, it should at least be controlled. In this chapter, we bring in some tools to help inspect and analyze the produced webpack bundle, and to make suggestions for improvement.

Dependencies #

This time we install dependencies that are not strictly necessary for application development. Some of them are optional; some are interchangeable. Mixing all dependencies into one bucket is not a good idea for multiple reasons. We need a clear view of the dependencies that contribute directly to development, next those needed on a case‑by‑case basis (e.g., generating favicons only when the favicon design changes), and finally those that help analyze the application as a whole by investigating its behavior and user experience. The clarity we want can be achieved in different ways, but we are going to split the project into multiple parts.

Let's split the project into three projects: the application itself, the favicon generator (discussed in the "Favicon" chapter), and another one for analysis. Every project is placed in its own folder for clear separation. The final folder structure should look like this:

analysis
└──── node_modules
|    | package.json
|    | ...
|
app
└──── node_modules
|    | package.json
|    |   ...
|
favicon-generator
└──── node_modules
|    | package.json
|    | ...
|
favicon-generator.config.js

Create a folder favicon-generator and move the contents of the old favicon-generator into it. Create a new folder app and move all existing code into it. Create one more folder — analysis — to host and run the analysis application. Finally, install the dependencies.

mkdir favicon-generator
cd favicon-generator
npm i cosmiconfig eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser -D --save-exact
  • cosmiconfig - library for loading/parsing config files
  • eslint - TypeScript/JavaScript files linting library
  • @typescript-eslint/eslint-plugin - eslint plugin for TypeScript
  • @typescript-eslint/parser - TypeScript parser for eslint
mkdir analysis
cd analysis
npm init -y
npm i size-limit @size-limit/preset-app bundle-buddy eslint \
fastify fastify-auto-push fastify-compress lighthouse pem \
pino-pretty ts-node typescript webpack-bundle-analyzer \
@types/node @types/pem @typescript-eslint/eslint-plugin @typescript-eslint/parser \
-D --save-exact
  • size-limit - performance budget tool, calculates the size of the application bundle
  • @size-limit/preset-app - size-limit preset for applications the produce bundles
  • bundle-buddy - identifies bundle duplication across splits
  • fastify - Node.js web framework for running a local HTTP server
  • fastify-auto-push - fastify plugin for HTTP/2 automatic server push
  • fastify-compress - content compression support for fastify (gzip, deflate, brotli)
  • pino-pretty - output log formatter for fastify logs
  • pem - on demand SSL certificate creation for HTTPS support
  • lighthouse- web application analysis library, inspects performance metrics and dev best practices
  • webpack-bundle-analyzer - webpack output files/bundle visualizer

Favicon generator #

This time we are not changing much for the favicon-generator. We are only changing how it is configured. At the moment we have a config.ts file with paths relative to the favicon-generator folder itself. This creates a long‑term management problem. There is a hidden connection between the favicon generator and the project/folder that uses its results, but the consumer of the generated artifacts doesn’t know about it, making refactoring more painful. Instead, place a config file at the root of all apps that configures the favicon generator with paths relative to the config file. Also, use a library to standardize how config files are loaded (cosmiconfig).

favicon-generator.config.js #

Create favicon-generator.config.js in the root of all projects and copy the contents of favicon-generator/config.ts into it, making adjustments for the Node.js module format:

module.exports = {
    source:  '/app/assets/images/logo.jpg',
    destination: '/app/assets/favicons',
    html: {
        source: '/app/webpack/index.html',
        replacementRoot: '../assets/favicons',
        marker: {
            start: '<!-- <favicon> -->',
            end: '<!-- </favicon> -->'
        }
    },

    favicons: {
       // ...
    }
}

The rest of the changes are just to read the new config itself as well as use it in the code.

favicon-generator/utils/read-config.ts #

Create favicon-generator/utils/read-config.ts:

import path from 'path'
import { cosmiconfig } from 'cosmiconfig'
import { Config } from './common'

const normalizePath = (baseDir: string, value: string): string =>
    path.join(baseDir, value)

const normalizePaths = (config: Config, baseDir: string): Config => ({
    ...config,
    source: normalizePath(baseDir, config.source),
    destination: normalizePath(baseDir, config.destination),
    html: {
        ...config.html,
        source: normalizePath(baseDir, config.html.source)
    }
})

export const readConfig = async (): Promise<Config> => {
    const explorer = cosmiconfig('favicon-generator')
    const result = await explorer.search()

    if(!result) {
        console.log('No config file found')
        process.exit(1)
    }

    const dirPath = path.dirname(result.filepath)
    const config = normalizePaths(result.config, dirPath)

    return config
}

We are reading the config file contents using the library and normalizing all the paths to be absolute paths using the config file location itself.

favicon-generator/index.ts #

Change favicon-generator/index.ts:

import { readConfig } from './utils/read-config'
import { generate } from './utils/generate-data'
import { deleteFiles } from './utils/delete-files'
import { save } from './utils/save-data'

const run = async (): Promise<void> => {
    const config = await readConfig()
    const data = await generate(config)
    await deleteFiles(config)
    await save(config, data)
}

run()
.catch(console.log)

This change is just to propagate the new config file content for the rest of the methods.

Analysis #

As discussed above, we are creating a new project for all the analysis needs. This project will consist of the combination of the several packages intended for the application bundle inspection and best practices investigation. Mainly, we are going to be looking into three things:

  • bundle size and how much time it will take a real browser to download the bundle
  • bundle split duplication and allocation inspection
  • best practices of the application performance and loading time

Bundle size #

It is always useful to know how much a real application weights in a browser. And that is what size-limit package is for. It loads the bundle into the headless/desktop Chrome and calculates its size and time spent to load it. Configuration of the library is performed via a JSON file.

analysis/.size-limit.json #

Create analysis/.size-limit.json:

[{
    "name": "app",
    "path": [
        "../app/dist/*.js",
        "../app/dist/*.json",
        "!../app/dist/stats.json",
        "!../app/dist/vendor.*",
        "!../app/dist/runtime.*"
    ],
    "running": false
},
{
    "name": "webpack",
    "path": [
        "../app/dist/vendor.webpack*.js"
    ],
    "running": false
},
{
    "name": "vendor",
    "path": [
        "../app/dist/vendor.*.js",
        "../app/dist/runtime*.js"
    ],
    "running": false
},
{
    "name": "all",
    "path": [
        "../app/dist/*.js",
        "../app/dist/*.json",
        "!../app/dist/stats.json"
    ],
    "running": false
}]

Here we've defined a sequence of separate buckets that will be downloaded and measured by the browser.

Bundle buddy #

When the application bundle is split into parts, there is always a chance that, due to misconfiguration, different splits contain duplication. That adds up to the total bundle size. bundle-buddy helps identify this problem. If the bundle is fine, it reports that; otherwise, it presents a visual representation of duplicates.

Webpack bundle analyzer #

This tool is used mostly for visualization. If you need to know how the bundle is laid out with all of its dependencies, or what takes the majority of space within the bundle, this is the right tool for the job. Essentially, it opens a web page with a heat map of bundle parts by size.

Lighthouse #

Our analysis wouldn’t be complete without Lighthouse. Lighthouse is a tool for inspecting web pages against different benchmarks with suggestions for improvement. We’ll look at performance and best practices. One of Lighthouse’s recommendations is to use HTTP/2. For HTTP/2 to be used, HTTPS has to be enabled as well. To accomplish that we create a local HTTP server that can serve HTTPS traffic over HTTP/2 with compression (gzip).

analysis/server.ts #

Create analysis/server.ts:

import fastify from 'fastify'
import { staticServe } from 'fastify-auto-push'
import compress from 'fastify-compress'
import pem from 'pem'
import path from 'path'

const parseArguments = (): [string, string] => {
    const [ url, dir ] = process.argv.slice(2)

    if(!url) {
        console.log('Missing url parameter')
        process.exit(1)
    }

    if(!dir) {
        console.log('Missing directory parameter')
        process.exit(1)
    }

    return [url, dir]
}

const createCertificate = (): Promise<pem.CertificateCreationResult> =>
    new Promise((resolve, reject) => {
        pem.createCertificate({ days: 1, selfSigned: true }, (err, keys) => {
            if(err) {
                reject(err)
                return
            }

            resolve(keys)
        })
    })

const main = async (): Promise<void> => {

    const [urlParameter, dirParameter] = parseArguments()
    const url = new URL(urlParameter)
    const isHttps = url.protocol === 'https:'

    const certificate: pem.CertificateCreationResult | null =
        isHttps ? (await createCertificate()) : null

    const app = fastify({

        ...(isHttps ? {
            https: {
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                key: certificate!.serviceKey,
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                cert: certificate!.certificate
            },
            http2: true
        } : {}),
        logger: {
            timestamp: false,
            prettyPrint: { colorize: true },
            serializers: {
                req: (req: {
                        method: string
                        url: string
                        headers: { [_: string]: string }
                    }): string =>
                    `${req.method} ${req.url} ${req.headers['user-agent'].substring(50)}`
            }
        }
    })

    app.register(compress)

    // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
    //@ts-ignore
    app.register(staticServe, {
        root: path.resolve(__dirname, dirParameter),
    })

    await app.listen(url.port)
}

main().catch((err) => {
  console.error(err)
  process.exit(1)
})

To achieve this, we use the fastify framework for the local HTTP server. It supports HTTP/2 out of the box. We also use fastify-auto-push for serving static files over HTTP/2 server push, fastify-compress for gzip support, and pem for on‑the‑fly self‑signed SSL certificate generation. The code above stitches these pieces together and reads two command‑line parameters: the URL to listen on and the path to the folder to serve.

Scripts #

With all the information at hand, we need to invoke the analysis libraries while running a local HTTP server.

All analyses require the application to be built in production mode and to point to its dist folder.

analysis/package.json #

Change analysis/package.json:

"scripts": {
    "analyze": "bundle-buddy ../app/dist/*.map && size-limit",
    "display": "webpack-bundle-analyzer ../app/dist/stats.json",
    "lighthouse": "lighthouse https://localhost:8080 --chrome-flags=\"--headless --ignore-certificate-errors\" --only-categories=performance,best-practices --view --output-path=./lighthouse/results.html",
    "lint": "eslint './**/*.ts' --max-warnings=0",
    "serve": "ts-node server.ts http://localhost:8080 ../app/dist",
    "serve:https": "ts-node server.ts https://localhost:8080 ../app/dist",
  }
  • analyze - runs bundle-buddy with the application map file parameter, followed by size-limit
  • display - runs webpack-bundle-analyzer. This opens a web page with a bundle heat map view. It needs statistics from stats.json.
  • lighthouse - runs Lighthouse analysis. We ignore the self‑signed certificate warning, select performance and best practices for inspection categories, and tell Lighthouse to output the HTML report to a specific path
  • serve - runs localhost HTTP server listening on port 8080 (http://localhost:8080) serve:https - runs localhost HTTPS server listening on port 8080 (https://localhost:8080)

Let's add complementary script entries to app/package.json as well (for convenience):

app/package.json #

Change app/package.json:

"scripts": {
...

    "stats": "cross-env TS_NODE_PROJECT=\"./webpack/tsconfig.json\" NODE_ENV=production webpack --config ./webpack/webpack.config.ts --profile --json > ./dist/stats.json",
    "analyze:ci": "npm run build:prod && npm run analyze --prefix ../analysis",
    "analyze": "npm run stats && npm run analyze --prefix ../analysis && npm run display --prefix ../analysis",
    "serve": "npm run serve --prefix ../analysis",
    "serve:https": "npm run serve:https --prefix ../analysis",
    "lighthouse": "npm run lighthouse --prefix ../analysis",
 }
  • stats - generates webpack bundle statistics under ./dist/stats.json
  • analyze:ci - analysis script intended to be run in a CI environment
  • analyze - analysis script intended to be run locally
  • serve - runs HTTP based application server
  • serve:https - runs HTTPS based application server
  • lighthouse - run lighthouse analysis

Results #

Finally, we’ve reached the point where we can see the results of everything we’ve been doing. It’s not just this chapter’s setup; it’s the whole puzzle of configuring webpack parts that comes together into modern web application development.

Let's run those scripts one by one and see what happens.

cd app
npm run stats

This just generates webpack statistics in a JSON format. It also builds the final bundle in production mode and copies all the artifacts into the dist folder.

npm run analyze

This one outputs results from bundle-buddy, size-limit, and webpack-bundle-analyzer. You should see something like this:

No bundle duplication detected 📯.

  app
  Size:         3.46 KB
  Loading time: 70 ms   on slow 3G

  webpack
  Size:         1.45 KB
  Loading time: 29 ms   on slow 3G

  vendor
  Size:         56.48 KB
  Loading time: 1.2 s    on slow 3G

  all
  Size:         59.94 KB
  Loading time: 1.2 s    on slow 3G

Webpack Bundle Analyzer is started at http://127.0.0.1:8888
Use Ctrl+C to close it

The next step requires two scripts to run at the same time: the local HTTPS server and the Lighthouse analysis. Start the server first in one shell:

npm run serve:https

It outputs:

[] INFO  (16806 on ...): Server listening at https://127.0.0.1:8080

And the Lighthouse in another shell:

npm run lighthouse

This should open up the page with a result similar to this one:

lighthouse report

Final version #

Reference implementation (opens in a new tab)