Skip to main content

Bundle analysis

Finally, it is time to analyze what we have accomplished so far. Webpack setups could be quite complex. That complexity might take down all the efforts put in place. If it cannot be eliminate, at least it should be controlled. In this chapter we are going to bring some tools to help us inspect and analyze webpack bundle produced as well as make some suggestions for improvement.

Dependencies

This time we are going to be installing dependencies that are not strictly speaking necessary for the application development. Some of them are optional, some are questionable and could be substituted with something else. Mixing all of the dependencies into one bucket is not a good idea for multiple reasons. We do need to see clearly all the dependencies that contribute directly to the development of the application. Next are those needed on a case by case scenario (ex. generating favicons only when favicon design changed). And finally, those that contribute to the application as a whole investigating its behavior and user experience for the potential corrections. The clarity we are looking for could be accomplished in a different ways but the one we going to use is to split project into multiple parts.

Let's split the project into three projects: the application itself, favicon generator - discussed in "Favicon" chapter, and another one for analysis. Every project is placed in its own folder for thr clear separation. 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 favicon-generator in there. Create a new folder app and move all the existing code into it. Create one more folder - analysis that is going to be used as the analysis hosting / running application. Finally, we get to install all 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 - NodeJS web framework for running 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. Everything we are doing is just changing the way is it configured. At the moment we have a config.ts file with path values set up relative to the favicon-generator folder itself. This creates a long term management problem. There is a hidden connection between favicon generator and the project/folder that uses its results, but the consumer of the generated artifacts does not know about it. So, refactorings are a bit more painful. Let's have a config file at the root of all apps that configures favicon generator with all the paths starting with the location of the config file itself. Also, we are bringing the library to standardize the way config files are used (cosmiconfig).

favicon-generator.config.js

Create favicon-generator.config.js in the root of all projects and copy the contents of the favicon-generator/config.ts into it, making additional adjustments for NodeJs 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 in parts, there is alway a chance that with some misconfiguration, different splits might contain duplication. That adds up to the total bundle size. bundle-buddy helps to identify this problem. Upon inspection, if the bundle is fine, it gives message informing just that, overwise there is a visual representation of duplicates within the bundle presented.

Webpack bundle analyzer

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

Lighthouse

Our analysis wouldn't be complete without the Lighthouse. Lighthouse is a tool for inspecting webpages against different benchmarks with suggestions for improvement. We are going to be looking into performance and best practices. One of the lighthouse recommendations is to use HTTP/2 protocol. For HTTP/2 to be used, HTTPS has to be enabled as well. To accomplish all of that we need to create a personal HTTP server that can server HTTPS traffic over HTTP/2 protocol in a compressed (gzip) way.

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 our goal, we are using fastify framework for the local HTTP server. It supports HTTP/2 protocol out ot the box. We also use fastify-auto-push for static files serving over HTTP/2 server push, fastify-compress for gzip support and pem for on the fly self signed SSL certificate generation. The code above just combines pieces together also reading two command line parameters: url to listen to and a path to the folder to be served.

Scripts

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

All analysis require the application to be built in production mode and 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 application map file parameter followed by size-limit library
  • display - run webpack bundle analyzer. This opens up a web page with bundle heat map view. To display the view it needs statistics results from stats.json.
  • lighthouse - runs Lighthouse analysis. We ignore self signed certificate warning, select performance and best practices for inspection categories and tell lighthouse to output the HTML report under certain 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 complimentary script entries for the app/package.json as well (just for the sake of 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 information under ./dist/stats.json
  • analyze:ci - analysis script that is intended to be ran under CI environment
  • analyze - analysis script that is intended to be ran under local environment
  • serve - runs HTTP based application server
  • serve:https - runs HTTPS based application server
  • lighthouse - run lighthouse analysis

Results

Finally, we got the point where we can see the results of everything we've been doing so far. It is not just this chapter for setting up analysis part. It is the whole puzzle of setting up webpack parts that comes together into what is known as modern web application development today.

Let's run those scripts one by 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 gives the output from the bundle-buddy, size-limit and webpack-bundle-analyzer. You should get 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

Next one requires two scripts to be running at the same time: the local HTTPS based server and the Lighthouse analysis library. We start the server first in one shell:

npm run serve:https

It gives the out of:

[] 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