Code splitting with webpack and TypeScript

profile picture

Spencer Miskoviak

September 8, 2019

Photo by Brendon Thompson

Code splitting is an approach to break apart a single large file into many smaller files. A common pattern is to code split at the route or page level because a user only needs the code for the current page, but not the fifty other or so pages in an application.

As with most things, code splitting has its tradeoffs. It requires a build tool that supports it, a specific configuration, and can add additional complexities that might be overkill for a small application. However, at a certain point the benefits start to outweigh the cost. Shipping a single large bundle has a number of downsides that generally results in poor performance.

One of the biggest downsides is the cost to download a single large file. It consumes additional bandwidth which can take longer to download and costs users with limited bandwidth actual money by forcing them to download more than they need. After downloading, the browser also has to parse and compile all that code which adds a non-trivial amount of time. If it's a very large file, the main thread will likely be blocked as a single, long task so other important tasks will have to wait for however long that takes. Finally, assuming this file is being cached, the entire file will have to be invalidated anytime even a single byte is changed so users will have to re-download everything.

These problems are what code splitting can help solve (along with some other benefits). Only the code for the current page or feature needs to be downloaded so less bandwidth is used and less time spent waiting. Since it's less code, that means the parsing and compilation is also faster. Even if the code splitting results in downloading multiple files this can happen in parallel. If one feature's code is changed, only that code split chunk needs to be invalidated rather than all the code. Lastly, this results in smaller tasks on the main thread that could be interleaved with other important work if necessary.

Code splitting is well supported in most builds tools like webpack, Parcel, or rollup but also some frameworks have this built-in, such as Gatsby or Next.js.

The remainder of this post is going to focus on using webpack as the build tool with a TypeScript and React application.

TypeScript

Since webpack is responsible for most the work it doesn't take long to get code splitting working with TypeScript. However, there are a handful of potentially subtle "gotcha's" to be aware of when working with TypeScript.

ESNext modules

The first step is to properly configure the module setting. For code splitting to work with webpack, it must be set to esnext. Dynamic imports were introduced in TypeScript 2.4. This allows imports to be executed dynamically to lazy load modules. However, for code splitting to work with webpack these dynamic imports must be left as is and not transpiled by TypeScript. Setting the module to esnext will preserve the dynamic imports in the output for webpack to handle and perform code splitting.

// tsconfig.json
{
  "compilerOptions": {
    "module": "esnext"
    // other configuration ...
  }
}

Without this setting, the build will likely still work, but it will be a single large bundle rather than many smaller chunks.

Magic comments

Another feature webpack supports are magic comments to help control the output and behavior of code split modules. As the name suggests, this relies on using actual comments (eg: /* webpackChunkName: "my-chunk-name" */).

TypeScript has a compiler option removeComments. This will remove all comments except copy-right header comments. Fortunately, this setting defaults to false but ensure it's not enabled. Otherwise, the magic comments will be removed and webpack won't ever see them and won't do anything.

// tsconfig.json
{
  "compilerOptions": {
    // since this is the default, it can also be omitted,
    // as long as it's not set to true
    "removeComments": false
    // other configuration ...
  }
}

TypeScript webpack configuration

If you're webpack configuration is not written in TypeScript this section can be skipped. However, when working with TypeScript and webpack chances are you at least considered writing your webpack configuration in TypeScript.

It does introduce some additional complexity (hence the need for this section) but the benefit of type-safety when working with webpack outweighs this cost in my opinion. It's easy to typo a configuration setting, or nest it in the wrong configuration object. Fortunately, using TypeScript can help alleviate this entire class of issues and reduce the amount of time digging into unexpected webpack output.

If you were not using esnext modules before this and relying on the same tsconfig.json for both source code and the webpack configuration a new tsconfig.json will be necessary specifically for your webpack configuration. ts-node is used to run webpack configurations written in TypeScript and it doesn't support esnext modules. Therefore, another module setting like commonjs is necessary. This can be done by creating another file such as tsconfig.webpack.json and then prefixing any or your webpack commands with TS_NODE_PROJECT=tsconfig.webpack.json. It may look something like the following.

// tsconfig.webpack.json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "esModuleInterop": true
  }
}
# In your terminal or package.json "scripts":
TS_NODE_PROJECT=tsconfig.webpack.json yarn webpack
TS_NODE_PROJECT=tsconfig.webpack.json npm run webpack

Without this, ts-node will continue to default to tsconfig.json and will likely throw SyntaxError: Unexpected identifier when trying to run webpack.

These are the main "gotcha's" I have experienced when working with TypeScript and webpack. Aware of other ones? Let me know on Twitter!

React

Now that TypeScript is configured to allow code splitting, the application code needs to be updated to use dynamic imports.

React.lazy and React.Suspense

React v16.6.0 introduced React.lazy and React.Suspense.

For example, let's assume you have a simple static (no code splitting) application that looks like the following.

import PageA from "./PageA";
import PageB from "./PageB";

const App = () => (
  <div>
    <PageA />
    <PageB />
  </div>
);

The first step is to update the imports to be dynamic and wrapped with React.lazy. It expects a function as the argument, and that function returns a dynamic import that will return a promise that resolves to a module where the component is the default export.

const PageA = React.lazy(() => import("./PageA"));
const PageB = React.lazy(() => import("./PageB"));

const App = () => (
  <div>
    <PageA />
    <PageB />
  </div>
);

Finally, as these modules are resolving (loading) we need a way to suspend rendering (since they haven't been loaded) and possibly show a loading state (if appropriate). For this we can use React.Suspense. The fallback prop will be rendered while waiting for PageA or PageB to load.

const PageA = React.lazy(() => import("./PageA"));
const PageB = React.lazy(() => import("./PageB"));

const App = () => (
  <div>
    <React.Suspense fallback={<div>Loading...</div>}>
      <PageA />
      <PageB />
    </React.Suspense>
  </div>
);

Unfortunately, if you're doing server rendering React.lazy and React.Suspense currently will not work. The current recommendation is to use Loadable Components for this.

Non-default exports

As mentioned React.lazy only works with default exports. If all your components are already default exports feel free to skip this section. If not, there are a number of options. First, the component being imported can be updated to be the default export.

// PageA.js
-export { PageA };
+export default PageA;

Second, as suggested in the docs, an intermediate module could be created to re-export the component as a default.

// SomeComponents.js
export { PageA };
export { OtherComponent };
// PageA.js
export { PageA as default } from "./SomeComponents";

Or lastly, the dynamic import can be modified to assign the default.

const PageA = React.lazy(() =>
  import("./PageA").then(importedModule => ({
    default: importedModule.PageA
  }))
);

For more details on code splitting with React, see the React doc's on Code Splitting.

webpack

With webpack 4, code splitting should be enabled by default without any extra work! This is great because it makes it easy to get started. However, these default settings will likely need to be adjusted to optimize code splitting for your application.

Dynamic Imports

To "enable" code splitting the only thing required is a dynamic import. As already mentioned in the TypeScript configuration changes and in the React setup the use of dynamic imports is the key to code splitting. A basic example is shown in the webpack code splitting guide to lazily load lodash.

function getComponent() {
  // Lazily load the "lodash" package and name
  // the code split chunk "lodash" using the
  // webpackChunkName magic comment.
  return (
    import(/* webpackChunkName: "lodash" */ "lodash")
      // Rename the default import to "_"
      // (done by convention with lodash, not necessary)
      .then(({ default: _ }) => {
        // Create a new HTML element
        const element = document.createElement("div");
        element.innerHTML = _.join(["Hello", "webpack"], " ");
        return element;
      })
      .catch(error => "An error occurred")
  );
}

// Lazily load the "component". Once the promise to
// load the module has resolved, append the
// element to the body.
getComponent().then(component => {
  document.body.appendChild(component);
});

When webpack finds a dynamic import, it will assume that code should be code split and lazy loaded. Technically, you could stop here and officially have done code splitting! However, there's likely a reasonable amount of optimization that can still be done.

"Dynamic" Dynamic Imports

The webpack documentation refers to this as "Dynamic expressions in import()". I've found the term "dynamic dynamic imports" to paint a better picture of what this means in my head but both terms are referring to the same thing. The idea behind this is to dynamically generate a dynamic import. This is useful when working with a number of modules that follow a strict convention. For example, as again demonstrated in the webpack documentation a basic example may be a directory of JSON files for different locales that need to be loaded using a dynamic import dynamically based on the current user.

// Determine what language the current user needs
const language = detectVisitorLanguage();

// Lazily load the current user's language out
// of the set of all possible languages defined
// in the ./locale directory
import(`./locale/${language}.json`).then(module => {
  // Do something with the translations...
});

To parse this, webpack will effectively convert this statement to regex so ./locale/${language}.json will become something like /^\.\/locale\/.*\.json$/.

This will match files such as ./locale/english.json and ./locale/spanish.json and will create chunks for each of these files that match. But with great power comes great responsibility. If this dynamic expression is too broad it may build files that will never be imported. Make sure to use as specific of a dynamic expression as possible and minimize the number of files that could match otherwise build performance could suffer.

Since each application is unique a dynamic expression import may never be needed, but it's a good technique to keep in mind.

Optimizing Split Chunks

The optimization.splitChunks configuration options provides control over how webpack will produce chunks. By default, webpack uses a set of heuristics when determining how to produce chunks to roughly find a balance between chunk size versus the number of chunks.

With chunks that are too large, we start to run into similar problems as having a single large bundle. The time to download the larger files, the time to parse, the lack of cache-ability, etc. However, with too many chunks, we can start to run into limitations with the number of requests a browser can make and the corresponding overhead. However, if using HTTP/2 this is not an issue in which case AggressiveSplittingPlugin may be a good option to explore.

Although webpack has reasonable defaults, it may be useful to explicitly split out certain portions of code. For example, say there's a set of shared common components that are used throughout an application. It may be desirable to force these into their own chunk. As with most things, make sure to test the implications and profile the impacts on webpack build performance, the output files (webpack bundle analzyer is a great tool for this) and browser performance using a tool like Lighthouse.

const config = {
  // other configuration ...

  optimization: {
    splitChunks: {
      cacheGroups: {
        // custom cache group to force all components files
        // in /components/common into their own chunk
        common: {
          name: "common-components",
          test: /[\\/]components[\\/]common[\\/]/,
          enforce: true
        },
        // default defined by webpack
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        }
      }
    }
  }
};

Keep in mind that the defaults for splitChunks differs in production versus development mode so when profiling make sure to run in production mode.

Tree Shaking

Although tree shaking (or dead code elimination) isn't directly tied to code splitting it can begin to make problems more obvious. It's easy for large modules to get lost in a single bundle. When working with many chunks, it becomes easier to see what the biggest chunks are and then begin to start diagnosing the root cause of large modules.

For more details and a specific example of tree shaking, read Tree Shaking Font Awesome.

Performance Budgets

Now that you've done all this work to code split an application and minimize the amount of code a user needs to download make sure to protect against regressions!

Although it's not bullet proof, performance budgets can be a great way to at least catch large regressions and intentionally decide if a slightly larger entry point or chunk size is acceptable rather than sneaking in.

Again, webpack has reasonable defaults for the performance budget but it may need to be adjusted.

By default the performance.hints will be set to "warning". However, if you want to prevent large regressions it's desirable to set this to "error" to fail the webpack build. In development, due to the lack of minification and differing split chunks defaults it may be preferable to disable these warnings altogether by setting it to false since the warnings will be inaccurate.

The maxEntrypointSize represents all assets that will be loaded during the initial load time for a specific entry. In the example below, it's set to an arbitrary 200 kilobytes.

The maxAssetSize is any file emitted by webpack. In the example below, it's set to an arbitrary 100 kilobytes.

And finally, the assetFilter allows controlling which files this applies to. In this example, it's not applied to any files ending with .css or .map.

const KILOBYTES = 1024;

const config = {
  // other configuration ...

  performance: {
    hints: process.env.NODE_ENV === "production" ? "error" : false,
    maxEntrypointSize: 200 * KILOBYTES,
    maxAssetSize: 100 * KILOBYTES,
    assetFilter: fileName =>
      !fileName.endsWith(".css") && !fileName.endsWith(".map")
  }
};

Preloading

One final consideration is to preload chunks that a given page will need. This is not required, but can help with browser performance by fetching known assets as early possible. There are a number of approaches to achieve this.

First, using the preload/prefetch magic comments /* webpackPrefetch: true */ (probably needed for some future navigation) or /* webpackPreload: true */ (resource needed during the current navigation).

However, this doesn't work well with "dynamic" dynamic imports or working with a complex server-rendered and client-rendered application. For this, a chunk group manifest file can be generated at build time to know which chunks are necessary for a given page and dynamically inject <link rel="preload"> tags. For an example of how a plugin like this might look, check out Gatsby's webpack stats extractor.

Finally, as mentioned earlier, exploring HTTP/2 Server Push could also be another consideration to download necessary assets as early as possible and avoid request overhead.

Conclusion

Hopefully this overview was helpful in getting started with code splitting a TypeScript, React and webpack-built application. Although webpack is complex, it fortunately ships with reasonable defaults for code splitting, performance budgets and other settings. Support for code splitting is only improving with great support in webpack and recent features shipping in React with React.lazy and React.Suspense.

If you're concerned about the performance of your application consider giving code splitting a shot!

Tags:

course

Practical Abstract Syntax Trees

Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.

Check out the course