- Introduction
- Setup
- Starting State
- Tree Shaking
- Minification
- Use only what is needed
- Code Splitting
- Lazy loading
- Production mode
- Compression
- Other options
- Bundle analyzer
- Conclusion
- References
Introduction
In modern web development, all webpage assets like html
, css
, js
, etc are bundled together into a smaller set of assets. This helps in reducing network calls from the client/user agent, and gives a much smaller file to download. This in turn gives a fast and efficient user experience, which is what we all want.
There are various tools available that manage the size of bundled assets. We are going to use the example of a popular and widely used bundler named Webpack, and practically look at many of the optimization techniques it offers.
Setup
Let's start with a basic node + react setup. It's okay if you don't have expertise in this. Ensure you have node
and npm
and npx
installed.
We are using webpack5
, but the concepts are more or less transferable across versions with minor modifications.
My node
and npm
version details:
webpack-optimization-demo % node --version
v20.11.1
webpack-optimization-demo % npm --version
10.2.4
You can get the initial code from repo. Follow these steps:
-
git clone git@github.com:viv1/blog-code-examples.git
-
git checkout dab817ec0a502adff4c82070c58ee36bb79e7bc0
-
cd blog-code-examples/webpack-optimization-demo
, then runnpm install .
. - You can go directly to section Starting State.
If you would like to do it from scratch, follow along:
- Let's initiate a node project:
mkdir webpack-optimization-demo
cd webpack-optimization-demo
npm init -y
- Install libraries and dependencies:
npm install --save-dev webpack webpack-cli webpack-dev-server style-loader css-loader babel-loader @babel/core @babel/preset-env @babel/preset-react html-webpack-plugin
npm install --save prop-types process lodash react react-dom webpack
- Create the following files in this directory structure:
This is a simple single page app with 2 components.
App.js
is cretaed using Component1
and a Component2
. Component1
and Component2
are simple text displays. Component1
is shown by default with a button.
When button is clicked, it displays Component2
.
unUsedComponent
is not really used anywhere.
- src
- components
- Component1.js
- Component2.js
- unUsedComponent.js
- App.js
- index.html
- index.js
- webpack.config.js
- package.json
- package.lock.json
Component1.js
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
const Component1 = () => {
let x = _.join(['C', 'o', 'm', 'p', 'o', 'n', 'e', 'n', 't', ' 1'], '');
return <div>{x}</div>;
}
Component1.propTypes = {
title: PropTypes.string,
description: PropTypes.string,
count: PropTypes.number,
};
export default Component1;
Component2.js
import React from 'react';
const Component2 = () => {
return <div>Component 2</div>;
}
export default Component2;
const unUsedComponent = () => {
console.log("Using-unUsedComponent");
return (
<div>unUsedComponent</div>
)
}
export default unUsedComponent;
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Webpack Optimization</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
App.js
import React, { useState } from 'react';
import Component1 from './components/Component1';
import Component2 from './components/Component2';
import unUsedComponent from './components/UnUsedComponent';
const App = () => {
const [showComponent2, setShowComponent2] = useState(false);
const handleClick = () => {
setShowComponent2(true);
}
return (
<div>
<Component1 />
<button onClick={handleClick}>Load Component 2</button>
{showComponent2 && <React.Suspense fallback={<div>Loading...</div>}>
<Component2 />
</React.Suspense>}
</div>
);
}
export default App;
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
mode: 'none',
entry: {
main: { import: "./src/index.js" }
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
filename: "index.html",
template: "./src/index.html",
}),
// fix "process is not defined" error:
new webpack.ProvidePlugin({
process: 'process/browser',
}),
]
};
Starting State
Let's first build the assets and see if our starting set is all good.
Run npx webpack
to build the assets:
webpack-optimization-demo % npx webpack
asset main.js 1.8 MiB [compared for emit] (name: main)
asset index.html 201 bytes [compared for emit]
runtime modules 1.25 KiB 6 modules
modules by path ./node_modules/ 1.78 MiB
modules by path ./node_modules/prop-types/ 28.6 KiB 6 modules
modules by path ./node_modules/react/ 92.5 KiB 3 modules
modules by path ./node_modules/react-dom/ 1.11 MiB 3 modules
modules by path ./node_modules/scheduler/ 21.4 KiB 3 modules
modules by path ./node_modules/react-is/ 9.5 KiB 3 modules
+ 3 modules
modules by path ./src/ 3.27 KiB
modules by path ./src/components/*.js 710 bytes
./src/components/Component1.js 342 bytes [built] [code generated]
+ 2 modules
modules by path ./src/*.js 2.58 KiB
./src/index.js 181 bytes [built] [code generated]
./src/App.js 2.4 KiB [built] [code generated]
webpack 5.91.0 compiled successfully in 743 ms
Notice the bundle size as 1.8MiB
(Your bundle sizes might be slightly different)
Let's also see how our page looks:
Run npx http-server -p 8000
. Now, open your web browser, and open developer tools. Go to Network tab.
Now load http://localhost:8000/dist
on your web browser. Your page should look like this:
If we click on button for Loading Component 2
, we see that no new network call was made even though Component 2
was loaded.
Now that our starting point is all good, we are going to look into how we can optimize this code.
At any point, feel free to complete delete the
dist
folder since it gets generated automatically whennpx webpack
is run.
Tree Shaking
Let's start with [Tree Shaking]{https://webpack.js.org/guides/tree-shaking/}.
It's a method to remove any unused modules.
unUsedComponent
is not really used in the display. However, it is still present in the code:
If we go to dist/main.js. Search for Using-unUsedComponent
, we see that it is present.
Let's start fixing this:
- Add this in webpack.config.js under
modules.exports
and then runnpx webpack
:
optimization: {
usedExports: true,
},
webpack-optimization-demo2 % npx webpack
asset main.js 1.79 MiB [emitted] (name: main)
asset index.html 201 bytes [compared for emit]
runtime modules 1010 bytes 5 modules
modules by path ./node_modules/ 1.78 MiB
modules by path ./node_modules/prop-types/ 28.6 KiB 6 modules
modules by path ./node_modules/react/ 92.5 KiB 3 modules
modules by path ./node_modules/react-dom/ 1.11 MiB 3 modules
modules by path ./node_modules/scheduler/ 21.4 KiB 3 modules
modules by path ./node_modules/react-is/ 9.5 KiB 3 modules
+ 3 modules
modules by path ./src/ 3.27 KiB
modules by path ./src/components/*.js 710 bytes
./src/components/Component1.js 342 bytes [built] [code generated]
+ 2 modules
modules by path ./src/*.js 2.58 KiB
./src/index.js 181 bytes [built] [code generated]
./src/App.js 2.4 KiB [built] [code generated]
webpack 5.91.0 compiled successfully in 735 ms
Not much change in file size. However, if we go to dist/main.js. Search for unUsedComponent
, we see it is NOT present.
Just like that, we have removed an unused component we created.
Minification
Next, we will do some minification. Minification is a process to reduce the total size of file contents by removing characters and parts which are deemed useless for machines.
FOr example, spaces in codes are simply a way for them to be readable by humans. However, from machine point of view, these are harmless. Minification helps remove these additional spaces among other things. Let's use an external plugin to help minify further.
We will install a minimizer plugin::
npm install --save-dev terser-webpack-plugin
Now, add this in webpack.config.js under modules.exports
and then run npx webpack
:
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
// ... other config
optimization: {
// ...
minimize: true,
minimizer: [new TerserPlugin()],
},
}
webpack-optimization-demo % npx webpack
asset main.js 520 KiB [emitted] [minimized] (name: main) 1 related asset
asset index.html 201 bytes [compared for emit]
runtime modules 1010 bytes 5 modules
modules by path ./node_modules/ 1.78 MiB
modules by path ./node_modules/prop-types/ 28.6 KiB 6 modules
modules by path ./node_modules/react/ 92.5 KiB 3 modules
modules by path ./node_modules/react-dom/ 1.11 MiB 3 modules
modules by path ./node_modules/scheduler/ 21.4 KiB 3 modules
modules by path ./node_modules/react-is/ 9.5 KiB 3 modules
+ 3 modules
modules by path ./src/ 3.27 KiB
modules by path ./src/components/*.js 710 bytes
./src/components/Component1.js 342 bytes [built] [code generated]
+ 2 modules
modules by path ./src/*.js 2.58 KiB
./src/index.js 181 bytes [built] [code generated]
./src/App.js 2.4 KiB [built] [code generated]
webpack 5.91.0 compiled successfully in 3011 ms
Notice the reduction in file size. From 1.79MiB
to 520KiB
. Around 70% savings
in size.
Use only what is needed
Notice that in Component1.js
, we only use join but we are instead importing the whole lodash
library.
lodash
has split each function in their own library file, so we can just download lodash/join
instead of the whole things.
Make the following code change in Component1.js
:
Component1.js
// ...
import join from 'lodash/join';
// ...
let x = join(['C', 'o', 'm', 'p', 'o', 'n', 'e', 'n', 't', ' 1'], '');
And then run npx webpack
:
webpack-optimization-demo % npx webpack
asset main.js 451 KiB [emitted] [minimized] (name: main) 1 related asset
asset index.html 201 bytes [compared for emit]
runtime modules 786 bytes 4 modules
modules by path ./node_modules/ 1.27 MiB
modules by path ./node_modules/prop-types/ 28.6 KiB 6 modules
modules by path ./node_modules/react/ 92.5 KiB 3 modules
modules by path ./node_modules/react-dom/ 1.11 MiB 3 modules
modules by path ./node_modules/scheduler/ 21.4 KiB 3 modules
modules by path ./node_modules/react-is/ 9.5 KiB 3 modules
+ 3 modules
modules by path ./src/ 3.34 KiB
modules by path ./src/components/*.js 779 bytes
./src/components/Component1.js 411 bytes [built] [code generated]
+ 2 modules
modules by path ./src/*.js 2.58 KiB
./src/index.js 181 bytes [built] [code generated]
./src/App.js 2.4 KiB [built] [code generated]
webpack 5.91.0 compiled successfully in 2543 ms
Notice the further reduction is asset size.
Code Splitting
Code Splitting is a way to split the bundled asset into multiple bundled assets, which can be loaded on demand, or in parallel.
We will see how to code split here, and take advantage of it in this and the next section.
Do demonstrate this better, let's create a new page which only loads Component1
. Remember that Component1
uses lodash/join
- Create 'NewApp.js' inside
src
, which only loadsComponent1
:
NewApp.js
import React from 'react';
import Component1 from './components/Component1';
const AppNew = () => {
return (
<div>
<Component1 />
</div>
);
}
export default AppNew;
- Create 'newindex.js' inside
src
: newindex.js
import React from 'react';
import ReactDOM from 'react-dom';
import AppNew from './AppNew';
ReactDOM.render(<AppNew />, document.getElementById('root'));
Let's now update webpack.config.js
to the following:
webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const webpack = require('webpack');
module.exports = {
mode: 'none',
entry: {
main: { import: "./src/index.js", dependOn: "shared" },
newindex: { import: "./src/newindex.js", dependOn: "shared" },
shared: "lodash/join",
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
filename: "index.html",
template: "./src/index.html",
chunks: ["main", "shared"],
}),
new HtmlWebpackPlugin({
filename: "newindex/index.html",
template: "./src/index.html",
chunks: ["newindex", "shared"],
}),
// fix "process is not defined" error:
new webpack.ProvidePlugin({
process: 'process/browser',
}),
],
optimization: {
usedExports: true,
minimize: true,
minimizer: [new TerserPlugin()],
splitChunks: {
chunks: 'all',
},
},
};
Here, we are doing the following things:
- Modified
entry
to have 2 new values,main
&newindex
-
lodash/join
is used in both pages, so we have taken outlodash/join
into a new commonshared
asset -
output
specifies how would we name our new assets. - Add
HtmlWebpackPlugin
to be able to loadnewindex
page - Added
splitChunks
inoptimization
Let's run npx webpack
again:
webpack-optimization-demo % npx webpack
assets by path *.js 453 KiB
asset 3.js 449 KiB [compared for emit] [minimized] (id hint: vendors) 1 related asset
asset main.js 2.14 KiB [compared for emit] [minimized] (name: main)
asset shared.js 1.25 KiB [compared for emit] [minimized] (name: shared)
asset newindex.js 736 bytes [compared for emit] [minimized] (name: newindex)
asset newindex/index.html 287 bytes [compared for emit]
asset index.html 274 bytes [compared for emit]
Entrypoint main 451 KiB = 3.js 449 KiB main.js 2.14 KiB
Entrypoint newindex 449 KiB = 3.js 449 KiB newindex.js 736 bytes
Entrypoint shared 1.25 KiB = shared.js
runtime modules 3.18 KiB 6 modules
modules by path ./node_modules/ 1.27 MiB
modules by path ./node_modules/prop-types/ 28.6 KiB 6 modules
modules by path ./node_modules/react/ 92.5 KiB 3 modules
modules by path ./node_modules/react-dom/ 1.11 MiB 3 modules
modules by path ./node_modules/scheduler/ 21.4 KiB 3 modules
modules by path ./node_modules/react-is/ 9.5 KiB 3 modules
+ 3 modules
modules by path ./src/ 3.76 KiB
modules by path ./src/*.js 3 KiB
./src/newindex.js 190 bytes [built] [code generated]
+ 3 modules
modules by path ./src/components/*.js 779 bytes
./src/components/Component1.js 411 bytes [built] [code generated]
+ 2 modules
webpack 5.91.0 compiled successfully in 2620 ms
Notice how multiple different asset files are getting created, including one for shared.js
which only contains lodash/join
. We can find these files inside the dist/
folder.
At this point, let's run the server again and see how it all looks: npx http-server -p 8000
:
If we open https://localhost:8000/dist
, we see that newindex.js
is not loaded:
If we open https://localhost:8000/dist/newindex
, we see that main.js
is not loaded:
Lazy loading
Lazy loading simply means that loading of an asset should happen only on demand.
In our case, we have seen that Component2
gets shown to user only when the button has clicked. However, it is loaded upfront. We are goin to load this only on demand.
Update App.js
with following 2 changes:
App.js
import React, { useState, lazy } from 'react';
// ...
const Component2 = lazy(() => import('./components/Component2'));
// ...
Run npx webpack
, we see a new asset (4.js
in my case, it might be named differently for you) is created in the dist
:
webpack-optimization-demo % npx webpack
assets by path *.js 455 KiB
asset 3.js 449 KiB [compared for emit] [minimized] (id hint: vendors) 1 related asset
asset shared.js 3.2 KiB [compared for emit] [minimized] (name: shared)
asset main.js 2.06 KiB [compared for emit] [minimized] (name: main)
asset newindex.js 736 bytes [compared for emit] [minimized] (name: newindex)
asset 4.js 254 bytes [compared for emit] [minimized]
asset newindex/index.html 287 bytes [compared for emit]
asset index.html 274 bytes [compared for emit]
Entrypoint main 451 KiB = 3.js 449 KiB main.js 2.06 KiB
Entrypoint newindex 449 KiB = 3.js 449 KiB newindex.js 736 bytes
Entrypoint shared 3.2 KiB = shared.js
runtime modules 8.12 KiB 12 modules
modules by path ./node_modules/ 1.27 MiB
modules by path ./node_modules/prop-types/ 28.6 KiB 6 modules
modules by path ./node_modules/react/ 92.5 KiB 3 modules
modules by path ./node_modules/react-dom/ 1.11 MiB 3 modules
modules by path ./node_modules/scheduler/ 21.4 KiB 3 modules
modules by path ./node_modules/react-is/ 9.5 KiB 3 modules
+ 3 modules
modules by path ./src/ 3.8 KiB
modules by path ./src/*.js 3.04 KiB
./src/newindex.js 190 bytes [built] [code generated]
+ 3 modules
modules by path ./src/components/*.js 779 bytes
./src/components/Component1.js 411 bytes [built] [code generated]
+ 2 modules
webpack 5.91.0 compiled successfully in 2529 ms
What is this asset ? Le's open it (Find it in dist/4.js
).
We see that 4.js
contains our Component2
code.
Let;s now run npx http-server -p 8000
, open Developer Tools
and go to Network Tab
and open https://localhost:8000/dist
We see that 4.js
is not yet loaded.
Click on the button Load Component 2
and notice that 4.js
gets loaded now.
There you have it, Lazy (On-demand) Loading.
Production mode
We can provide different modes in the webpack config. We are currently running with none
mode. We can specify development
and production
mode.
In development
mode, the purpose is ease of development. In production
mode, the purpose is to give small, fast, optimized assets.
Let's change the mode in:
webpack.config.js
// ...
mode: 'production',
// ...
Run npx webpack
and notice how the asset size is reduced in size by a huge number.
webpack-optimization-demo % npx webpack
assets by path *.js 143 KiB
asset 248.js 137 KiB [emitted] [minimized] (id hint: vendors) 1 related asset
asset shared.js 3.13 KiB [emitted] [minimized] (name: shared)
asset main.js 1.94 KiB [emitted] [minimized] (name: main)
asset newindex.js 644 bytes [emitted] [minimized] (name: newindex)
asset 860.js 248 bytes [emitted] [minimized]
asset newindex/index.html 282 bytes [emitted]
asset index.html 269 bytes [emitted]
Entrypoint main 139 KiB = 248.js 137 KiB main.js 1.94 KiB
Entrypoint newindex 137 KiB = 248.js 137 KiB newindex.js 644 bytes
Entrypoint shared 3.13 KiB = shared.js
runtime modules 8 KiB 11 modules
orphan modules 2.88 KiB [orphan] 3 modules
cacheable modules 148 KiB
modules by path ./node_modules/ 144 KiB
modules by path ./node_modules/prop-types/ 2.6 KiB 3 modules
modules by path ./node_modules/react/ 6.95 KiB 2 modules
modules by path ./node_modules/react-dom/ 130 KiB 2 modules
modules by path ./node_modules/scheduler/ 4.33 KiB 2 modules
+ 1 module
modules by path ./src/ 3.61 KiB
modules by path ./src/*.js 3.04 KiB 2 modules
modules by path ./src/components/*.js 579 bytes
./src/components/Component1.js 411 bytes [built] [code generated]
./src/components/Component2.js 168 bytes [built] [code generated]
webpack 5.91.0 compiled successfully in 1664 ms
Compression
We can also compress the files further to ensure a smaller asset is transferred over the network.
Run npm install compression-webpack-plugin --save-dev
, and then run npx webpack
.
webpack-optimization-demo % npx webpack
assets by path *.js 143 KiB
assets by status 5.7 KiB [emitted]
asset shared.js 3.13 KiB [emitted] [minimized] (name: shared) 1 related asset
asset main.js 1.94 KiB [emitted] [minimized] (name: main) 1 related asset
asset newindex.js 644 bytes [emitted] [minimized] (name: newindex) 1 related asset
assets by status 137 KiB [compared for emit]
asset 248.js 137 KiB [compared for emit] [minimized] (id hint: vendors) 2 related assets
asset 860.js 248 bytes [compared for emit] [minimized] 1 related asset
asset newindex/index.html 282 bytes [emitted] 1 related asset
asset index.html 269 bytes [emitted] 1 related asset
Entrypoint main 139 KiB = 248.js 137 KiB main.js 1.94 KiB
Entrypoint newindex 137 KiB = 248.js 137 KiB newindex.js 644 bytes
Entrypoint shared 3.13 KiB = shared.js
runtime modules 8 KiB 11 modules
orphan modules 2.88 KiB [orphan] 3 modules
cacheable modules 148 KiB
modules by path ./node_modules/ 144 KiB
modules by path ./node_modules/prop-types/ 2.6 KiB 3 modules
modules by path ./node_modules/react/ 6.95 KiB 2 modules
modules by path ./node_modules/react-dom/ 130 KiB 2 modules
modules by path ./node_modules/scheduler/ 4.33 KiB 2 modules
+ 1 module
modules by path ./src/ 3.61 KiB
modules by path ./src/*.js 3.04 KiB 2 modules
modules by path ./src/components/*.js 579 bytes
./src/components/Component1.js 411 bytes [built] [code generated]
./src/components/Component2.js 168 bytes [built] [code generated]
webpack 5.91.0 compiled successfully in 1656 ms
There is no difference in this small example. But the difference can be more significant in larger projects. (It's also possible the compression done in the production mode is comparable in this case, so does not make any difference here).
Other options
There are still many other steps we can take like External Dependency where we loaded common large dependencies during run-time from CDN, Purge Unsued CSS, Module Concatenation and many more, depending on our specific needs. I will not go into it for the sake of keeping this article consumeable.
Bundle analyzer
I will however mention one very useful plugin Webpack Bundle Analyzer. It graphically helps us visualize which modules are taking how much space and where we should look to optimize. It's also interactive.
npm install --save-dev webpack-bundle-analyzer
Update the webpack.config.js
:
webpack.config.js
// ...
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
// ...
module.exports = {
// ...
plugins: [
// ...
new BundleAnalyzerPlugin(),
],
// ...
}
Run npx webpack
and then open http://localhost:8888/
and we should see something like this:
Conclusion
Optimizing the bundle size of any web application is essential for ensuring a seamless and performant user experience. By leveraging the power of webpack and its optimization techniques, we can significantly reduce the amount of data transferred over the network, leading to faster load times and improved overall performance. Continuously monitoring and refining the webpack configuration will help in maintaining a high-performing and responsive application over time.
References
- Webpack
- Tree Shaking
- Code Splitting
- External Dependency
- Purge CSS
- Module Concatenation
- Webpack Bundle Analyzer
This article was originally published in my personal blog
Top comments (0)