BlogReactJS

Reduce your react bundle size with webpack code splitting

Written by Codemzy on March 23rd, 2022

Is Webpack telling you your bundle sizes are too big? Is all of your ReactJS code in a single app.js bundle? In this blog post, I look at why you should try code splitting and how to split your code up into smaller chunks.

When you build an application with ReactJS or any other JavaScript framework, you need to give that code to your users. You will use a bundler of some sort, like Webpack, to bundle your app together into a JavaScript file that the browser can understand.

Let's say your webpack.config.js looks something like this:

const path = require('path');

module.exports = {
  entry: {
    app: './src/app.js',
  },
  target: ['web', 'es5'],
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
  },
  resolve: {
    extensions: ['.js', '.jsx']
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      }
    ]
  },
};

And maybe that all works fine out the box, to start with. But as you add more features, your app grows. Webpack warns you that your file is too big with warnings about asset size limits and exceeding the recommended size limit.

It's time to think about code splitting!

What is code splitting?

Code splitting involves splitting one massive javascript bundle up into several smaller chunks.

Code splitting is one of the most compelling features of webpack. This feature allows you to split your code into various bundles which can then be loaded on demand or in parallel.

- Webpack Code Splitting

Let's imagine your app has a few areas. The billing area, the dashboard, feature A, and feature B.

A user logs in, views the dashboard, uses feature B and logs out again. They don't even touch the billings area or feature A. There's really no need for them to download the code associated with those areas.

With code splitting, the user avoids downloading unnecessary code, and your app runs faster because of it!

You can also split your code from some big modules that don't change too often. Let's start with a quick win by doing that.

Split up with ReactJS

If you frequently make changes to your app and deploy lots of updates, then users are downloading your app, plus all of the ReactJS code each time you update.

You need ReactJS for your app to work, so we can't get rid of it. But we can split it from our app.

This won't reduce the amount of code the browser downloads the first time a user visits your website. But it will mean that when you update your app, the browser will only need to re-download the changed app.js file and not react-module.js.

To separate ReactJS from the rest of your code you can use optimization.splitChunks to create a specific chunk.

const path = require('path');

module.exports = {
  entry: {
    app: './src/app.js',
  },
  target: ['web', 'es5'],
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
  },
  resolve: {
    extensions: ['.js', '.jsx']
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      }
    ]
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        reactVendor: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
          name: 'vendor-react',
          chunks: 'all',
        },
      },
    },
  },
};

Webpack calls code that comes out of node_modules vendor. So we have created a cache group called "reactVendor" and named our output file vendor-react.

reactVendor: {
  test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
  name: 'vendor-react',
  chunks: 'all',
},

In the test key we tell Webpack to include any code coming from react, react-dom, or react-router-dom. That's all of my ReactJS dependencies, but you may wish to add other ReactJS packages you use.

When you run Webpack after this change, you'll get two files. Your app.js file as before, which should be much smaller, and a new vendor-react.js file.

You've just split your code! Because these are both entry point files, and your new import isn't a dynamic import (more on this later) you're going to need to include the new JavaScript file in your HTML. Your app needs the react code to work, so include it in the <head> rather than the body of your HTML.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Your Awesome App</title>
    <script src="./vendor-react.js"></script>
  </head>
  <body>
    <script src="./app.js"></script>
  </body>
</html>

Now the browser can cache ReactJS, and only re-download it when there's an update (like the ReactJS version you are using changes).

Configure and split core-js

As a web app, you need to support some older browsers that might not fully support all the latest JavaScript features you want to use. Like Promise and Object.assign.

At the entry of your ReactJS app, you might have this.

import "core-js/stable";

I did. I followed the instructions when replacing @babel/polyfill and thought no more about it. It's just a little bit of code to support older browsers after all!

It wasn't until I ran my bundle through Webpack Visualizer that I realised core-js was taking up a massive chunk of my bundle size.

I started by tweaking my .babelrc file to only support browsers with over 0.25% usage that is not dead (as in not being developed anymore).

{ 
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "> 0.25%, not dead",
        "useBuiltIns": "entry",
        "corejs": "3.8"
      }
    ], 
    "@babel/preset-react"
  ] 
}

This configuration resulted in a small reduction in size, but nothing dramatic.

By the way, you can shrink core-js down massively by only supporting browsers with over 1% use, but it may limit the number of people in older browsers able to access your app.

Since this module will rarely change, you can also split out the code here so that it isn't re-downloaded by the user every time you update your app.

corejsVendor: {
  test: /[\\/]node_modules[\\/](core-js)[\\/]/,
  name: 'vendor-corejs',
  chunks: 'all',
},

Again, this is needed to run your app.js code, so make sure you add it to your HTML file.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Your Awesome App</title>
    <script src="./vendor-corejs.js"></script>
    <script src="./vendor-react.js"></script>
  </head>
  <body>
    <script src="./app.js"></script>
  </body>
</html>

Dynamic code splitting your app

Now you've seen how you can do the manual stuff, splitting out bigger modules like react and core-js from your code.

But what about splitting up your app? Remember the scenario earlier where a user downloaded all the code for feature A but only accessed feature B? How can you solve that?

First, you can add one more line of code to your webpack.config.js. Under output you can add a chunkFilename, for example [name].bundle.js, to tell Webpack what to name these new chunks.

output: {
  filename: '[name].js',
  chunkFilename: '[name].bundle.js',
  path: path.resolve(__dirname, 'dist'),
},

To split your app into features or areas, you can use route-based code splitting.

For example, you can separate your FeatureA routes into a file routes/FeatureA that might look like this.

import React from "react";
import { Switch, Route, Redirect } from 'react-router-dom'
import * as paths from './paths';
// components
import Awesome from '../components/feature-a/Awesome';
import Cool from '../components/feature-a/Cool';
import Amazing from '../components/feature-a/Amazing';

// feature a routes
function FeatureARoutes() {
  return (
    <Switch>
      <Route exact path={paths.featureAAwesome} component={Awesome} />
      <Route exact path={paths.featureACool} component={Cool} />
      <Route exact path={paths.featureAAmazing} component={Amazing} />
      <Route path={paths.featureA}><Redirect push to={paths.featureAAwesome} /></Route>
    </Switch>
  );
};

export default FeatureARoutes;

And now if you use React.lazy() function to import your routes, Webpack will know this is a dynamic import and create a separate bundle for those routes.

Your lazy import would look like this:

const FeatureA = lazy(() => import('./routes/FeatureA'));

But remember we want to name our bundles? To give your chunk a name, you can also pass a comment /* webpackChunkName: "feature-a" */ to Webpack with the import:

const FeatureA = lazy(() => import(/* webpackChunkName: "feature-a" */ './routes/FeatureA'));

In our example app, your router with code splitting might now look something like this.

import React, { Suspense, lazy } from 'react';

const Home = lazy(() => import(/* webpackChunkName: "home" */ './routes/Home'));
const Billing = lazy(() => import(/* webpackChunkName: "billing" */ './routes/Billing'));
const FeatureA = lazy(() => import(/* webpackChunkName: "feature-a" */ './routes/FeatureA'));
const FeatureB = lazy(() => import(/* webpackChunkName: "feature-b" */ './routes/FeatureB'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/billing" element={<Billing />} />
        <Route path="/feature-a" element={<FeatureA />} />
        <Route path="/feature-b" element={<FeatureB />} />
      </Routes>
    </Suspense>
  </Router>
);

This will give you several bundles, at least home.bundle.js, billing.bundle.js, feature-a.bundle.js and feature-b.bundle.js. Maybe even more if these separate areas share some common code.

The good news is you don't have to worry about adding these bundles anywhere. Because they are not entry point bundles, your app will dynamically load the chunks* when (and if) they are required!

Now your code is split into small bundles, and browsers will only download the parts they need.

*Deploying often? If you run into a ChunkLoadError, see How to fix ChunkLoadError in your ReactJS application.