David East, but the top half of the face

David East

Web Developer / Designer

Code-splitting with TypeScript and Webpack

Jun 6, 2018

Make sure you understand the configuration when code-splitting. I wrote this blog post so I can finally stop having this problem.

Code-splitting is a feature I’d pay for. It’s amazing. Take one large JavaScript file and break it into smaller pieces with one keyword.

Webpack looks for the dynamic import() and automatically splits that code into a separate bundle. TypeScript and Webpack lets us write module syntax today. Instead of using modules natively, Webpack bundles them together.

async function loadLazily() {
  const module = await import("some-module");
}

Webpack takes code above and transforms it something the browser can understand. The code for run-time lazily loading is even provided by Webpack. Thanks, Webpack.

This code sample looks simple. However, getting it to work requires knowledge of both TypeScript and Webpack configurations. Otherwise, things go wrong.

The problem

The other week I built a TypeScript and Webpack app. I was using the following code:

async function loadFirebaseApp() {
  const firebase = await import("firebase/app");
  return firebase.initializeApp({ /* config */ });
}

It’s just a simple way to lazily load Firebase. I expected Webpack to break the app into two chunks, but it didn’t.

$ webpack
Hash: 936a8a03c861adc4bab8
Version: webpack 4.10.2
Time: 1136ms
Built at: 2018-06-03 07:45:09
    Asset     Size  Chunks             Chunk Names
    bundle.js     54.7    main  [emitted]  main

The code is in one bundle. In other words, there’s no code-splitting. What gives? I’m using the dynamic import(). There should be at least two bundles. Let’s break down my configuration.

The configuration

It’s important to understand how it the final code is built.

TypeScript compiles the code based on your tsconfig.json. This was the tsconfig.json I was using:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "sourceMap": true,
    "outDir": "dist",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"]
  }
}

The "target" JavaScript version is "es5" and the "module" format is "commonjs". Webpack understands the require('') function as well as ES modules. I figured it didn’t matter which I chose.

Spoiler alert: I was wrong.

Now Webpack takes over and bundles the compiled code based on your webpack.config.js.

const CopyWebpackPlugin = require("copy-webpack-plugin");
module.exports = {
  mode: "development",
  devtool: "inline-source-map",
  entry: "./src/index.ts",
  output: {
    filename: "bundle.js"
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js"]
  },
  module: {
    rules: [{ test: /.tsx?$/, loader: "ts-loader" }]
  },
  plugins: [
    new CopyWebpackPlugin([{ from: "./src/index.html", to: "index.html" }])
  ]
};

Don’t be intimidated by this config file, it’s actually quite simple. Webpack looks at the "entry" file to create the bundle. The "resolve" config tells Webpack to look at files that end in .ts, .tsx, or .js. The TypeScript code is compiled to JavaScript with the "module" config. This done through the ts-loader module. Lastly, I’m using the copy-webpack-plugin to move over assets like index.html to my built folder: "dist".

All this seems right. So how do we debug to figure why the all the code is bundled in one file?

The gotcha

To debug I’ll compile the TypeScript code without running it through Webpack. I temporarily change the "target" setting to "es2017" to avoid the down-leveled async/await code.

This will help me see what Webpack is bundling.

async function loadFirebaseApp() {
  const firebase = await Promise.resolve().then(function() {
    return require("firebase/app");
  });
  return firebase.initializeApp({
/* config */
  });
}
loadFirebaseApp().then(app => {
  console.log("i has app");
});

The output is not what I expected. There’s no dynamic import(), just a Promise wrapped around the require() function. This is how TypeScript translates the dynamic import() for the "commonjs" setting. The problem is, Webpack won’t see an import() keyword, and won’t code-split the bundle.

The fix is simple: change the "module" configuration to "esnext". This time the compiled code looks more like what’s expected.

async function loadFirebaseApp() {
  const firebase = await import("firebase/app");
  return firebase.initializeApp({});
}
loadFirebaseApp().then(app => {
  console.log("i has app");
});

Changing the "module" setting won’t affect the compiled version of JavaScript (which is still be set to "es5"). Webpack transforms the module imports to something the browser can understand.

Now my tsconfig.json looks like this:

{
  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "strict": true,
    "sourceMap": true,
    "outDir": "dist",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"]
  }
}

When I run Webpack again, I’ll finally see two bundles:

$ webpack
Hash: 77b7a56470c7c5a1da42
Version: webpack 4.10.2
Time: 2556ms
Built at: 2018-06-03 14:28:34
  Asset      Size  Chunks             Chunk Names
  0.bundle.js  52.3 KiB       0  [emitted]
  bundle.js  3.53 KiB       1  [emitted]  main

The main bundle is 3.5kb (1.7kb gzipped), which will load quickly on page load. The code-split bundle is 52.4kb (18.4kb gzipped) which is much easier to load after the page is up and running.

Know the configuration

It only took one setting to break my code-splitting set up. This underscores how important it is to understand what’s going on under the hood.

Don’t think about TypeScript or Webpack as a black box. Learn what each tool is responsible for. Learn where TypeScript compilation ends and Webpack bundling begins. This allowed me to easily debug my problem and fix it within minutes. Even if I’ve done it several times.