← Go Back

Code-splitting is hard

Published: Fri Nov 13 2020

Occasionally code-splitting and chunking is avoided as a performance optimisation because it is thought of as complex or “black-magic”. I hope to address those fears, because in most cases code-splitting could be done with relative ease and may reward you with considerable gains; especially for apps which are heavily reliant on JavaScript.

The hardest part about code-splitting may be deciding on how to split your code & bundles. This is not convered in this article, but there is lots of material on this topic. You may want to read: Improved Next.js and Gatsby page load performance with granular chunking or The 100% correct way to split your chunks with Webpack

The code for this demo is available on GitHub. Contributions & feedback are always welcome.

The demo is built using Webpack v4 (v5 update coming soon!) and ReactJS, using React.lazy route-based code-splitting and react-router-dom.

Will stylesheets be bundled in separate chunks?

If one or more stylesheets are imported in a single module or its dependencies, then a reference to the bundled stylesheet will only be included in that module’s chunk. For clarity, a chunk may consist of more than one file (JS & CSS).

In our example, Page1 imports a stylesheet:

  import styles from "./style.scss";

As it is the only file which references this stylesheet, then it will be chunked. Additionally, Page1 is the only module which imports the Glider component; which in turn imports another two stylesheets and also has a dependency on the third-party glider-js.

src/js/components/glider/index.jsx

  import "glider-js";
  import "glider-js/glider.min.css";

All these will be included in a single chunk, together with the output of style.scss above.

On the contrary, if a stylesheet is imported in more than one module, then the bundler will output a single stylsheet referenced by both modules.

In our example, Page2 imports a shared stylesheet:

  import sharedStyles from "../home/style.scss";

This stylesheet is also imported in the Home module and therefore is not included in the Page2 chunk.

What about images?

By design, images are only downloaded when needed and present in the DOM. This means that images should have no impact on your bundle sizes.

If you are importing your images using file-loader’s esModule then you will also benefit from module concatenation and tree-shaking on your images; but this is not specifically code-splitting.

However, if you are using url-loader and your images are being encoded into Base64 or SVG strings, then they will be encoded into each chunk resulting in duplicate code.

May I use CommonJS imports?

Yes, CommonJS & ES6 module imports work equally well.

In our example, in Page2 the below two lines would result in equivalent chunks:

  const styles = require("./style.scss");
  //import stylesfrom "./style.scss");

When using route-based code-splitting, is it possible to have some routes lazy-loaded while others loaded regularly?

Yes, definitely.

In this demo, the Home module is loaded regularly while the other pages are loaded lazily.

  import Home from "../home";
  const Page1 = React.lazy(() => import("../page1"));
  const Page2 = React.lazy(() => import("../page2"));


  <Suspense fallback={null}>
    <Switch>
      <Route path="/1" exact>
        <Page1 />
      </Route>
      <Route path="/2" exact>
        <Page2 />
      </Route>
      <Route>
        <Home />
      </Route>
    </Switch>
  </Suspense>

Does code-splitting work with named exports?

React.lazy requires you to have a default export, however you may still use named exports for other components, even for those which are being referenced by the lazily loaded component.

What about re-exports? Will export * from "./my-module" be tree-shaken?

Using export * from "./my-module" means that any export in ./my-module, regardless of whether it is used or unused, would need to be evaluated and executed in case one of those exports has side-effects. As a result, you need to explicitly inform Webpack that the file has no side-effects using the sideEffects package.json property. Sean Larkin has an excellent explanation on Stack Overflow.

The example code includes a component Page3 which exports an unused component …/glider-named-export. Without sideEffects: false, the resultant chunk includes the contents of …/glider-named-export, even if it is never actually being used.

Does this work with critical (inlined) CSS?

Yes it does.

The configuration used in this demo inlines a single critical CSS file which includes all critical CSS defined across the project. This is done using the following code inside scripts/webpack.config.js:

  criticalStyles: {
    name: "critical",
    test: /critical\.(sa|sc|c)ss$/,
    chunks: "initial",
    enforce: true,
  }

The output of this chunk is then inlined in src/templates/index.hbs:

  <% if (/critical(\..*)?\.css$/.test(htmlWebpackPlugin.files.css[index])) { %>
    <style>
      <%= compilation.assets[htmlWebpackPlugin.files.cssindex].substr(htmlWebpackPlugin.files.publicPath.length)].source() %>
    </style>
  <% } %>

Will users have to wait to download the chunks on successive routes?

In theory yes, because these files were not downloaded yet and will only be downloaded once the user requests them by visiting the page; but this could be mitigated through the use of a service-worker precache which will download the remaining chunks after the initial page load.

In the demo I am using the highly recommended WorkboxPlugin:

  new WorkboxPlugin.InjectManifest({
    swSrc: `${ROOT_DIR}/src/sw.js`,
    swDest: "sw.js",
    include: [/\.js$/, /\.css$/],
  })

Thank you for reading and please leave me your feedback. As always, it would be very welcome & appreciated. If you want to get in touch, you could reach me on Twitter @imkevdev.

© 2024 Kevin Farrugia