An introduction to Webpack Code-Splitting, Loadable/component, and Resource Hints

Dominic Fraser
The Startup
Published in
14 min readAug 28, 2020
webpack, loadable components, and prefetch and preload resource hints

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.

example of bundlesize report with a max size of 125kb

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 https://loadable-components.com/docs/loadable-vs-react-lazy/
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 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 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.jsconst 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 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

link rel=”preload” shown in html snippet

‘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 not be relied on as part of a core chunking strategy. They are a value add, not part of the core proposition.

It’s worth noting that in my experience when experimenting with adding these hints in different places locally it was necessary to stop and restart the webpack dev server (ran with npm start in a Create React App app) to remove these — a frustrating lesson learning that on deleting a webpackPrefetch it was still being prefetched. Hopefully this is a ‘my machine’ problem, but worth testing.

https://caniuse.com tables for prefetch and preload

At the time of writing prefetch has no support in Safari, and preload has no support in Firefox.

These do not mean they should not be used, just that they should not be relied on.

They are defined in the Webpack docs as:

  • prefetch: resource is probably needed for some navigation in the future, fetches in network idle time
  • preload: resource might be needed during the current navigation, fetches at the same time
  • A preloaded chunk starts loading in parallel to the parent chunk. A prefetched chunk starts after the parent chunk finishes loading.
  • A preloaded chunk has medium priority and is instantly downloaded. A prefetched chunk is downloaded while the browser is idle.
  • A preloaded chunk should be instantly requested by the parent chunk. A prefetched chunk can be used anytime in the future.
  • Using webpackPreload incorrectly can actually hurt performance, so be careful when using it.

Prefetch

const OtherComponent = loadable(() =>
import(/* webpackPrefetch: true */ './OtherComponent'),
);

The above would trigger a prefetch of the bundle at the time the file containing it was executed.

It does this by adding a link to the head of the document, which then triggers a request. This means there are two areas to look to see if it is working — the head and the network panel.

It is important to note that the Webpack docs refer to it being used to load a component that is hidden behind an interaction handler — i.e. a component that is loaded only on being clicked. This is a great use case.

const [dynamicComponentThree, setDynamicComponentThree] = useState(null);return (
<div className={STYLES.container}>
<ComponentOne />
<button
type="button"
onClick={async () => {
const ComponentThree = await loadable(() => /* webpackPrefetch: true */ import('./ComponentThree'));
setDynamicComponentThree(ComponentThree);
}}
/>
{dynamicComponentThree && <DynamicComponentThree />}
</div>
);
};

Here when the file is parsed the prefetch is assigned to the ComponentThree chunk, meaning it has a chance to be already downloaded if it is clicked on (if there has been browser idle time to do it in).

Using our Routes example from before, imagine we updated to:

Routes.jsconst Homepage = loadable(() => /* webpackPrefetch: true */ import('../Homepage'));
const SearchResults = loadable(() => import('../SearchResults'));
const Routes = ({ history }) => (
<Router history={history}>
<Switch>
<Route exact path={HOMEPAGE} component={Homepage} />
<Route exact path={SEARCH} component={SearchResults} />
</Switch>
</Router>
);

If a user landed on Homepage this would have no impact, the prefetch would be picked up at the time Routes.js was executed, but the Homepage component would be matched and executed before the prefetch had any time to be useful.

However, if a user landed on SearchResults then the prefetch (for browsers that support it) would fetch the other chunk in idle time — useful if there is a strong likelihood that in our app a user would go to that page next.

Firefox is the best browser for seeing if this is working, as it helpfully adds a X-moz: prefetch header to any prefetched request.

Chrome currently seems flakey for honouring this hint, despite claiming to have full support. It is always possible to see Webpack add it to the head, but Chrome does not always initiate a request.

Preload

This is the more aggressive of the two, and can harm performance if over used for pages a user does not actually visit. Say we preloaded all the routes in Routes.js — it seems unlikely a user will visit all of these, so we have clogged up network and parse time for bundles that will not be used.

However, say the Homepage was made of two large components, it may be useful to download these in parallel — so creating two chunks and preloading them could result in a better experience. This is application specific knowledge of your user patterns.

If you remember how we mentioned doing inline imports in the ‘Import cascades’ section we can see this is a manual, cross browser support, way of essentially triggering preload behaviour. When this is parsed it will immediately be executed, starting a network request. This is not however recommended as it is not semantically clear why it is being done.

Loadable Components has an extra trick here to get around inconsistent browser support.

const OtherComponent = loadable(() =>
import('./OtherComponent'),
);
OtherComponent.preload()

It has a cross browser supported .preload() that can be used to force a network request — more consistent than using the webpackPreload magic comment if you happen to be using this library.

Final Thoughts

Creating different bundles is an extremely powerful way of increasing performance for your users. It benefits everyone, but particularly those on low powered mobile devices on non 4G networks — on slower networks large bundles can render apps completely unusable.

There is also a monetary cost to your users of downloading data — which again can exclude the less privileged of us.

There are some very easy wins for nearly all applications (Route based splitting) as well as micro optimisations for those with the time available (prefetching).

Where exactly to split your app and how to handle the split points is a choice individual to your app — you know your users best and what is important to them at any point in a given flow.

Hopefully this helps with how to define the split points, and links to other posts or resources are completely welcomed as comments.

Thanks for reading!

--

--