An introduction to Webpack Code-Splitting, Loadable/component, and Resource Hints
By: Dominic Fraser
Tags: Dynamic Import, Code Splitting, Loadable Component, Prefetch, Preload

So, you have a React application, understand the performance and therefore usability benefits of splitting the code you ship to your users into multiple smaller bundles (chunks) rather than one large bundle, and have heard of Webpack ‘dynamic imports’ but are not quite sure how to go about using them. This guide is for you!

We’ll look at different techniques of implementing code splitting, explore how to use Loadable Components to manage these, and outline how Webpack ‘magic comments’ can be used to generate prefetch and preload resource hints (and how useful these actually are).

This post was written using webpack@4.28.3 and @loadable/component@5.13.0 .

First let’s ask a deceptively simple question: how big should each code bundle be?

How big should each code bundle be?

The Cost of JavaScript 2019 advises:

Avoid having just a single large bundle; if a bundle exceeds ~50–100 kB, split it up into separate smaller bundles. (With HTTP/2 multiplexing, multiple request and response messages can be in flight at the same time, reducing the overhead of additional requests.)

There are many articles around this subject, but for the sake of brevity let’s take 100kb as a reasonable max size of any one bundle.

We can use bundlesize to measure this at build time. It analyses all bundles created and generates a report, and allows a max size of individual bundles to be specified as a pass/fail check.

This can even be enforced as a blocking check on pull requests if wanted, preventing code that would break any budget on the max size being merged. Importantly progressive improvements are possible here — if your application already exceeds a bundle of 100kb simply set it at the existing level, as this will ensure you don’t unknowingly degrade from the current size. Any time you make improvements you can then reduce this max size closer to 100kb.

Bundles vs Chunks

You may have noticed that Bundlesize refers to ‘bundles’, but each bundle has the extension ‘.chunk.js’.

These terms are nearly, but not completely, synonymous.

With references to the Webpack Glossary this post explains the differences between bundles and chunks best. For now we can say that when we make new code split points we create new chunks, which are then used to output final bundles. These normally have a 1:1 mapping hence seeming synonymous, but it is good to know this is not always the case.

Dynamic import syntax

Webpack ‘dynamic imports’ import a module as a runtime Promise, rather than the standard static build time module import. The syntax follows the ECMAScript proposal for adding import() to the core language, but it is currently Webpack specific.

The React docs and Webpack docs have examples of this in its base form:

Before:

import { add } from './math'; console.log(add(16, 26));

After:

import("./math").then(math => {

console.log(math.add(16, 26));

});

This allows it to be handled asynchronously, requested at runtime from a user’s browser. When import() is used Webpack creates a new chunk for the imported code, splitting it from the main bundle. We need to handle its use knowing it will not be immediately available (it sends a separate network request), thinking about network errors and loading states.

Handling Promises all over our app using .then() is a lot of mental overhead, and so for React specifically several libraries have been created that allow us to do this more efficiently. We will use @loadable/component here.

If the first could be termed an ‘inline import() ' the second could be an ‘assigned import() '.

import loadable from '@loadable/component'; const OtherComponent = loadable(() => import(../components/OtherComponent')); // then within current component using normal component syntax... <OtherComponent />

Libraries that allow us to pass in an import() statement in this way generally have guidance or APIs that show good error and loading practices.

Loadable Components

The three most popular libraries for handling dynamic imports are React.lazy , @loadable/component , and react-loadable .

React.lazy is part of React itself and is paired with <Suspense> , but is not recommended to use in production applications (though there is nothing technically stopping you). It does not support Server Side Rendering (SSR)

The React team recommend @loadable/component as a third party solution that does support SSR and is production ready. It can be used with <Suspense> or have a fallback specified via a prop. In addition it supports code splitting of libraries, not just React components.

Comparison table of React.lazy vs Loadable Components

react-loadable is a very popular older third party library that also supports SSR. However it is now unmaintained, and has even closed the ability to raise new GitHub issues. The documentation around code splitting in its README is still excellent contextual reading however.

We are going to use Loadable Components here.

Naming chunks

Webpack allows ‘magic comments’ to be placed within an import() statement. Multiple of these can be used in a comma separated list. One of these is webpackChunkName , which can be used to give a meaningful name to the output chunk.

For example, this import:

import(/* webpackChunkName: “LargeComponent” */ ‘../LargeComponent’);

Would result in this chunk:

build/static/js/LargeComponent.f391f849.chunk.js

Without the webpackChunkName hint, we get a anonymous chunk ID which is difficult to identify:

build/static/js/9.f391f849.chunk.js

Loadable Components assigns meaningful chunk names without this comment needing to be used, but are still compatible with it if we wanted to specify a different one.

Examples here that use Loadable Components will not use webpackChunkName , as the autogenerated one is generally exactly what we want.

Strategies

Code splitting strategy diagrams taken from react-loadable documentation

There are four main ways of choosing where to code split within your application; route based, component at render time based, component behind an event handler based, and library use based.

Another strategy is Vendor chunking. This is an automatic strategy to pull out common node module dependencies into a ‘vendors’ chunk that is also used (and advised) but will not be covered here. It is enabled by default in Create React App, though a future post may look at how to customise this further.

Route based

This is the easiest to achieve and can have a large immediate impact. A routing file and app entry point is needed by all users, but then depending on their URL a different bundle is requested. The experience of waiting for a page to load is familiar, so any latency requesting the second page’s bundle on navigation is not jarring to the user.

If you were using React Router the split points could look like:

const Homepage = loadable(() => import('../Homepage'));

const SearchResults = loadable(() => import('../SearchResults'));

const Help = loadable(() => import('../Help'));

const DefaultRoute = loadable(() => import('../DefaultRoute')); const Routes = ({ history }) => (

<Router history={history}>

<Switch>

<Route exact path={HOMEPAGE} component={Homepage} />

<Route exact path={SEARCH} component={SearchResults} />

<Route exact path={HELP} component={Help} />

<Route render={() => <DefaultRoute />} />

</Switch>

</Router>

); export default Routes;

A user landing on the homepage and making a search would never download the bundles for the help or default page, and so not suffer the performance hit of downloading and parsing unneeded JavaScript.

When landing on the homepage (or whatever their first page may be) they download the main bundle that includes the Routes component, and a second bundle containing the Homepage route when the matching <Route> renders. As the other routes do not match, so do not execute a render, their bundles are not requested.

If then moving to the SearchResults URL the new route would match, render, and the new bundle would be requested.

Component based

We can further optimise by splitting out individual components. This may be UI element that are initially not visible (modals, drawers, below-the-fold content) or that are not as high priority for the user (they can wait to see it without their experience suffering).

If these components are large then splitting them into separate chunks may make the central experience better as the central content loads faster.

However, be aware that this comes at a degradation of the loading experience of the second component — that modal will take slightly longer to load, or that lower priority component may have an initial loading spinner.

The decisions of when this makes sense or not are entirely dependant on your specific app and the experience you prioritise. Do you want the first load to be faster, or do you want completely seamless navigation when moving around the page? A balance somewhere between the two is most common.

Again the react-loadable documentation provides some great considerations here, despite it no longer being a recommended package due to no longer receiving updates.

One of these is to make sure to avoid flashes of loading components. A loading component shown for <200ms is more harmful to the perception of speed that showing nothing at all, and this should be taken into consideration when implementing loading states.

Component based route splitting falls into two types: at render time and behind a DOM event.

At render time

The simplest form this could take is a large component at the bottom of the page being split out as below.



import loadable from ' import React from 'react';import loadable from ' @loadable/component '; import ComponentOne from '../ComponentOne'

import ComponentTwo from '../ComponentTwo' import STYLES from './Homepage.scss'; const ComponentThree = loadable(() => import('./ComponentThree')); const Homepage = () => (

<div className={STYLES.container}>

<ComponentOne />

<ComponentTwo />

<ComponentThree fallback={<div>Loading...</div>}/>

</div>

);

The downloaded JavaScript for the bundle containing this page will not contain ComponentThree and instead when this file is executed the new bundle generated by the ComponentThree dynamic import will be requested. While waiting on the Promise to resolve (when the response is received) the fallback component will be shown.

On events

A component that is not shown until an event is triggered, for example a button click, can be even further split behind that event.

The advantage here is if the button if never clicked no request is even made, the disadvantage being the wait time if it is clicked. This can be optimised in some browsers using a ‘prefetch’ hint (covered later) but this does not work for all browsers. One additional strategy seen is, on desktop, to request the chunk on mouseover. You can see this navigating around the current Facebook desktop UI if you open the network tab and move the cursor around the page. On mobile however, where performance has the most impact, this has no effect.

Once the component is resolved the app then needs access to it to render it — unlike the component in the above example that was declared inline. One way of doing this is to store the component in state, and conditionally render the app based on it being there or not.

A simple example of this is provided by Enmanuel Durán. This could look like:



import loadable from ' import React, { useState } from 'react';import loadable from ' @loadable/component '; import ComponentOne from '../ComponentOne'; import STYLES from './Homepage.scss'; const Homepage = () => {

const [dynamicComponentThree, setDynamicComponentThree] = useState(null); return (

<div className={STYLES.container}>

<ComponentOne />

<button

type="button"

onClick={async () => {

const ComponentThree = await loadable(() => import('./ComponentThree')); setDynamicComponentThree(ComponentThree);

}}

/>

{dynamicComponentThree && <DynamicComponentThree />}

</div>

);

};

Please add any other good examples of this as comments to this post.

Library based

The final method is to split out large libraries that are not on the critical path of the app, requesting them just before they are actually needed.

This could be a library used for fetching advert data for example. Using inline imports and Promise chaining, or loadable.lib , these could be split out from the main app and the result of using them applied asynchronously.

This is the least common of all the above, but it is worth knowing it is possible. A future post may look more at this.

Import cascades

In the examples above we looked at one level deep of code splitting, e.g. a Route that import() s a screen. But what if that screen we import also has code split points within it?

If we combined the Route and component based splitting from the examples above we would have:

Routes.js const Homepage = loadable(() => import('../Homepage'));

const SearchResults = loadable(() => import('../SearchResults'));

const Help = loadable(() => import('../Help'));

const DefaultRoute = loadable(() => import('../DefaultRoute')); const Routes = ({ history }) => (

<Router history={history}>

<Switch>

<Route exact path={HOMEPAGE} component={Homepage} />

<Route exact path={SEARCH} component={SearchResults} />

<Route exact path={HELP} component={Help} />

<Route render={() => <DefaultRoute />} />

</Switch>

</Router>

);

Homepage.js

import loadable from ' import React from 'react';import loadable from ' @loadable/component '; import ComponentOne from '../ComponentOne'

import ComponentTwo from '../ComponentTwo' import STYLES from './Homepage.scss'; const ComponentThree = loadable(() => import('./ComponentThree')); const Homepage = () => (

<div className={STYLES.container}>

<ComponentOne />

<ComponentTwo />

<ComponentThree fallback={<div>Loading...</div>}/>

</div>

);

The code split points map to:

- (A) Routes, renders:

- (B) Homepage, renders:

- (C) ComponentThree

- (D) SearchResults

If a user lands on a Homepage URL then:

(A) will download, parse, and execute: this initiates the request for (B) as it matches that route

(B) will download, parse, and execute: this initiates the request for (C) as it is not gated behind anything (interaction, switch statement)

(D) will not be downloaded, as the JS in (A) never executes (renders) it

We will look in a moment at how we could download (D) if we wanted to in a deliberate way using prefetch/preload.

If inline imports ( import() not passed in to a function) were used then all bundles would be downloaded, as since they are not gated behind a function being invoked they would trigger at parse time. This is a side effect to be aware of rather than a strategy to adopt.

Prefetch/Preload resource hints

‘prefetch’ and ‘preload’ are resource hints that can be added to the HTML link element. Webpack allows these to be specified at chunk declaration time using webpackPrefetch and webpackPreload magic comments.

The webpack docs explain the differences here quite well, but what is important to remember is that they are hints; not all browsers support them and they should be relied on as part of a core chunking strategy. They are a value add, not part of the core proposition.