Introduction
Initially, I wasn't planning to write anything about this topic, but a recent experience of myself made me to think again about it. One of our codebases has been built using the create-react-app boilerplate, which over time has degraded, and slowly slowly became one of those projects that nobody wants to work with. For example, running the application in dev mode became extremely difficult and the whole team was searching for a solution to improve the developer experience(DX). Since I was the person who was assigned to this task, I looked for a solution that would give most of the performance benefits with the least changes. The team wasn't ready for a redesign, so any performance gains would be considered a plus. As a result, I am writing this post to describe my solution, which by no means didn't eliminate all the problems we had, however, it gives to the team some extra breathing space and keeps the project backwards compatible.
Project description
The project has been built using the create-react-app boilerplate, which under the hood uses webpack and babel to bundle and transpile javascript/typescript into browser compatible code. It also uses terser, which is a javascript minifier tool. Those tools, although well adopted within the industry, they started to become a bit outdated, and new tools are now available with a better performance. One such tool is esbuild, which is an extremely fast bundler written in go. Esbuild has an out-of-the-box support to Javascript, Typescript, CSS and JSX loaders, as well as its own javascript minifier. In order to give you a glimpse of the difference between babel and esbuild in performance, the latter is advertised as 10-100 times faster than the former. With that been said, it was a no brainer for me to experiment with esbuild and try to integrate it in our project.
By default, create-react-app uses babel-loader to transpile javascript/typescript into browser compatible code, so the idea was fairly straightforward. Rather than sending chunks of javascript/typescript code to babel, we had to forward them to the esbuild binary. In addition, we had to replace the terser minifier with esbuild. Unfortunately create-react-app keeps its configuration private, so in order to access it we had to either use create-react-rewired or craco. Both of these solutions introduce an intermediate configuration layer between your components and the create-react-app setup. With that way, we didn't need to eject our project, we could still follow all the changes in the official create-react-app repository, as well as adjust the configuration of the project based on our needs. At the end, we chose craco over create-react-rewired because it offers a Plugins API and a few utility functions that are very easy to use while working with the configuration.
Integrating esbuild with create-react-app
The integration was executed in two phases. Initially, we installed and configured craco in our project, and then replaced babel with esbuild. In order to configure carco, we installed the craco in our project, created the necessary files, and replaced react-scripts
with craco
command. All the necessary steps are described in craco's website. Then we replaced babel with esbuild. For this step, we added the below code in craco.config.js
and run it against the project.
const {
loaderByName,
removeLoaders,
addBeforeLoader,
} = require("@craco/craco");
const path = require("path");
const { ESBuildMinifyPlugin } = require("esbuild-loader");
const getPath = (...args) => path.resolve(__dirname, ...args);
const ErrAddingEsBuildLoader = new Error("err: failed to add esbuild loader");
const ErrRemovingBabelLoader = new Error("err: failed to remove babel-loader");
module.exports = {
webpack: {
configure: (webpackConfig) => {
const babelLoaderName = loaderByName("babel-loader");
const esbuildLoader = {
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: getPath("src"),
loader: require.resolve("esbuild-loader"),
options: {
loader: "tsx",
target: "es6",
},
};
const { isAdded } = addBeforeLoader(
webpackConfig,
babelLoaderName,
esbuildLoader
);
if (!isAdded) {
throw ErrAddingEsBuildLoader;
}
const { hasRemovedAny } = removeLoaders(webpackConfig, babelLoaderName);
if (!hasRemovedAny) {
throw ErrRemovingBabelLoader;
}
webpackConfig.optimization.minimizer = [
new ESBuildMinifyPlugin({
target: "es6",
css: true,
}),
];
return webpackConfig;
},
},
};
In this code, we configure the esbuild loader, inject it before the babel-loader and then remove babel. At the end, we alter the minimizer option in the webpack config adding the esbuild minify plugin. At that point the work was done and the only thing that has left was to benchmark the app and compare the build times. Here are the results:
build time using default configuration: 3m 50.88s
build time using esbuild: 58.67s
The build time of the project has dropped below 1 minute, which is almost 4 times faster than before.
Integrating esbuild using esbuild-craco plugin
Previously, we configured webpack ourselves, but there is also an existing plugin that allows to integrate esbuild with your personal project. The name of the plugin is esbuild-craco and you just simply need to include it in your craco plugins. Here is what I mean:
const CracoEsbuildPlugin = require("craco-esbuild");
module.exports = {
plugins: [{ plugin: CracoEsbuildPlugin }],
};
Summary
While create-react-app has gained a lot of attraction over time, I feel that it reached to a point that it can't compete with the rest of the tools out there. However, there are a lot of codebases that still using it and can't easily migrate away from it. Finding ways to optimize a project is key in order to keep it sustainable within the company, as well as to keep the developer experience in good levels. In our case, we integrated esbuild in our project, which is a new bundler written in a lower-level language such as go, and managed to improve the build time of our project by 4 times.
In the long term, we are planning to replace create-react-app with vite, which uses a combination of esbuild and rollup. Those tools can provide a better performance, as well as better features compared to the legacy create-react-app boilerplate.