DEV Community

Cover image for Practical and Beginner friendly guide for speeding up your web-apps
Wewake
Wewake

Posted on

Practical and Beginner friendly guide for speeding up your web-apps

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
Enter fullscreen mode Exit fullscreen mode

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 run npm 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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Component2.js

import React from 'react';

const Component2 = () => {
  return <div>Component 2</div>;
}

export default Component2;
Enter fullscreen mode Exit fullscreen mode
const unUsedComponent = () => {
    console.log("Using-unUsedComponent");
    return (
    <div>unUsedComponent</div>
    )
}
export default unUsedComponent;
Enter fullscreen mode Exit fullscreen mode

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Webpack Optimization</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));
Enter fullscreen mode Exit fullscreen mode

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',
    }),
  ]
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Expected Original UI

Your network tab:
Expected Network Tab

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 when npx 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 run npx webpack:
optimization: {
    usedExports: true,
  },
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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()],
  },
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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'], '');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 loads Component1:

NewApp.js

import React from 'react';
import Component1 from './components/Component1';

const AppNew = () => {
  return (
    <div>
      <Component1 />
    </div>
  );
}

export default AppNew;
Enter fullscreen mode Exit fullscreen mode
  • 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'));
Enter fullscreen mode Exit fullscreen mode

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',
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Here, we are doing the following things:

  1. Modified entry to have 2 new values, main & newindex
  2. lodash/join is used in both pages, so we have taken out lodash/join into a new common shared asset
  3. output specifies how would we name our new assets.
  4. Add HtmlWebpackPlugin to be able to load newindex page
  5. Added splitChunks in optimization

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
Enter fullscreen mode Exit fullscreen mode

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:

Expected Code Split Page1 Network Tab

If we open https://localhost:8000/dist/newindex, we see that main.js is not loaded:

Expected Code Split Page2 Network Tab

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'));
// ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
// ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Update the webpack.config.js:
webpack.config.js

// ...
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
// ...
module.exports = {
    // ...
  plugins: [
    // ...
    new BundleAnalyzerPlugin(),
  ],
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Run npx webpack and then open http://localhost:8888/ and we should see something like this:

Bundle Analyzer Plugin Example Output

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


This article was originally published in my personal blog

Top comments (0)