John Reilly MacGyver turned Dev 🌻❤️ TypeScript / ts-loader / fork-ts-checker-webpack-plugin / DefinitelyTyped: The Movie

webpack or esbuild: Why not both?

4 min read 1306

webpack or esbuild: Why not both?

Builds can be made faster using tools like esbuild. However, if you’re invested in webpack but would still like to take advantage of speedier builds, there is a way.

In this tutorial, we’ll show you how to use esbuild alongside webpack with esbuild-loader.

webpack or esbuild: Why not both?

The world of web development is evolving

With apologies to those suffering from JavaScript fatigue, the world of web development is evolving yet again. It’s long been common practice to run your JavaScript and TypeScript through some kind of Node.js-based build tool like webpack or rollup.js. These tools are written in the same language they compile to — namely, JavaScript or TypeScript.

The new kids on the blog are tools like esbuild, Vite, and swc. The significant difference between these and their predecessors is that the new-school tools are written in languages like Go and Rust. Go and Rust enjoy far better performance than JavaScript. This translates into significantly faster builds.

These new tools are transformative and likely represent the future of build tooling for the web. In the long term, the likes of esbuild, Vite, and friends may well come to displace the current standard build tools — the webpacks, rollups, and so on.

However, that’s the long term. There are a lot of projects out there that are already heavily invested in their current build tooling — mostly webpack. Migrating to a new build tool is no small task. New projects might start with Vite, but existing ones are less likely to be ported. There’s a reason webpack is so popular; it does a lot of things very well indeed. It’s battle-tested on large projects, it’s mature, and it handles a wide range of use cases.

So if your team wants to have faster builds but doesn’t have the time to go through a big migration, is there anything you can do? Yes, there’s a middle ground to be explored.

There’s a relatively new project named esbuild-loader. Developed by hiroki osame, esbuild-loader is a webpack loader built on top of esbuild. It allows users to swap out ts-loader or babel-loader with itself, which massively improves build speeds.

To declare an interest here for full disclosure, I’m the primary maintainer of ts-loader, a popular TypeScript loader that is commonly used with webpack. However, I feel strongly that the important thing here is developer productivity. As Node.js-based projects, ts-loader and babel-loader will never be able to compete with esbuild-loader in the same way. As a language, Go really, uh, goes!

We made a custom demo for .
No really. Click here to check it out.

While esbuild may not work for all use cases, it will work for the majority of tasks. As such, esbuild-loader represents a middle ground — and an early way to get access to the increased build speed that esbuild offers without saying goodbye to webpack. This walkthrough will explore using esbuild-loader in your webpack setup.

Migrating an existing project to esbuild

It’s very straightforward to migrate a project that uses either babel-loader or ts-loader to esbuild-loader. First, install the dependency:

npm i -D esbuild-loader

If you’re currently using babel-loader, make the following change to your webpack.config.js:

  module.exports = {
    module: {
      rules: [
-       {
-         test: /\.js$/,
-         use: 'babel-loader',
-       },
+       {
+         test: /\.js$/,
+         loader: 'esbuild-loader',
+         options: {
+           loader: 'jsx',  // Remove this if you're not using JSX
+           target: 'es2015'  // Syntax to compile to (see options below for possible values)
+         }
+       },

        ...
      ],
    },
  }

Or, if you’re using ts-loader, make the folllwing change to your webpack.config.js:

  module.exports = {
    module: {
      rules: [
-       {
-         test: /\.tsx?$/,
-         use: 'ts-loader'
-       },
+       {
+         test: /\.tsx?$/,
+         loader: 'esbuild-loader',
+         options: {
+           loader: 'tsx',  // Or 'ts' if you don't need tsx
+           target: 'es2015'
+         }
+       },

        ...
      ]
    },
  }

Creating a baseline application

Let’s see how esbuild-loader works in practice. We’re going to create a new React application using Create React App:

npx create-react-app my-app --template typescript

This will scaffold out a new React application using TypeScript in the my-app directory. It’s worth mentioning that Create React App uses babel-loader behind the scenes.

CRA also uses the Fork TS Checker Webpack Plugin to provide TypeScript type-checking. This is very useful because esbuild just does transpilation and is not designed to provide type-checking support. So it’s fortunate that we still have that plugin in place. Otherwise, we would lose type checking.

Now that you understand the advantage of moving to esbuild, we first need a baseline to understand what performance looks like with babel-loader. We’ll run time npm run build to execute a build of our simple app:

Completed Build for Create React App

Our complete build, TypeScript type checking, transpilation, minification and so on, all took 22.08 seconds. The question now is, what would happen if we were to drop esbuild into the mix?

Introducing esbuild-loader

One way to customize a Create React App build is by running npm run eject and then customizing the code that CRA pumps out. Doing so is fine, but it means you can’t keep track with CRA’s evolution. An alternative is to use a tool such as Create React App Configuration Override (CRACO), which allows you to tweak the configuration in place. CRACO describes itself as “an easy and comprehensible configuration layer for create-react-app.”

Let’s add esbuild-loader and CRACO as dependencies:

npm install @craco/craco esbuild-loader --save-dev

Then we’ll swap over our various scripts in our package.json to use CRACO:

"start": "craco start",
"build": "craco build",
"test": "craco test",

Our app now uses CRACO, but we haven’t yet configured it. So we’ll add a craco.config.js file to the root of our project. This is where we swap out babel-loader for esbuild-loader:

const { addAfterLoader, removeLoaders, loaderByName, getLoaders, throwUnexpectedConfigError } = require('@craco/craco');
const { ESBuildMinifyPlugin } = require('esbuild-loader');

const throwError = (message) =>
    throwUnexpectedConfigError({
        packageName: 'craco',
        githubRepo: 'gsoft-inc/craco',
        message,
        githubIssueQuery: 'webpack',
    });

module.exports = {
    webpack: {
        configure: (webpackConfig, { paths }) => {
            const { hasFoundAny, matches } = getLoaders(webpackConfig, loaderByName('babel-loader'));
            if (!hasFoundAny) throwError('failed to find babel-loader');

            console.log('removing babel-loader');
            const { hasRemovedAny, removedCount } = removeLoaders(webpackConfig, loaderByName('babel-loader'));
            if (!hasRemovedAny) throwError('no babel-loader to remove');
            if (removedCount !== 2) throwError('had expected to remove 2 babel loader instances');

            console.log('adding esbuild-loader');

            const tsLoader = {
                test: /\.(js|mjs|jsx|ts|tsx)$/,
                include: paths.appSrc,
                loader: require.resolve('esbuild-loader'),
                options: { 
                  loader: 'tsx',
                  target: 'es2015'
                },
            };

            const { isAdded: tsLoaderIsAdded } = addAfterLoader(webpackConfig, loaderByName('url-loader'), tsLoader);
            if (!tsLoaderIsAdded) throwError('failed to add esbuild-loader');
            console.log('added esbuild-loader');

            console.log('adding non-application JS babel-loader back');
            const { isAdded: babelLoaderIsAdded } = addAfterLoader(
                webpackConfig,
                loaderByName('esbuild-loader'),
                matches[1].loader // babel-loader
            );
            if (!babelLoaderIsAdded) throwError('failed to add back babel-loader for non-application JS');
            console.log('added non-application JS babel-loader back');

            console.log('replacing TerserPlugin with ESBuildMinifyPlugin');
            webpackConfig.optimization.minimizer = [
                new ESBuildMinifyPlugin({
                    target: 'es2015' 
                })
            ];

            return webpackConfig;
        },
    },
};

So what’s happening here? The script looks for babel-loader usages in the default Create React App config. There will be two: one for TypeScript/JavaScript application code (we want to replace this) and one for nonapplication JavaScript code. It’s not too clear what nonapplication JavaScript code there is or can be, so we’ll leave it in place; it may be important. The code we really care about is the application code.

You cannot remove a single loader using CRACO, so instead, we’ll remove both and add back the nonapplication JavaScript babel-loader. We’ll also add esbuild-loader with the { loader: 'tsx', target: 'es2015' } option set to ensure we can process JSX/TSX.

Finally, we’ll swap out using Terser for JavaScript minification for esbuild as well.

A tremendous performance improvement

Our migration is complete. The next time we build, we’ll have Create React App running using esbuild-loader without having ejected. Once again, we’ll run time npm run build to execute a build of our simple app and determine how long it takes:

Completed Build for Create React App With esbuild

Our complete build, TypeScript type-checking, transpilation, minification, and so on, all took 13.85 seconds. By migrating to esbuild-loader, we’ve reduced our overall compilation time by approximately one-third. This is a tremendous improvement!

As your codebase scales and your application grows, compilation time can skyrocket. With esbuild-loader, you should reap ongoing benefits to your build time.

: Debug JavaScript errors easier by understanding the context

Debugging code is always a tedious task. But the more you understand your errors the easier it is to fix them.

LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to find out exactly what the user did that led to an error.

LogRocket records console logs, page load times, stacktraces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!

.
John Reilly MacGyver turned Dev 🌻❤️ TypeScript / ts-loader / fork-ts-checker-webpack-plugin / DefinitelyTyped: The Movie

Leave a Reply