Taking a React application from source code to production is a multi-step process that requires many different tools, such as transpilers, bundlers, minifiers, and more. At Ensolvers we do our best to optimize our tooling to ensure we can deploy our customs solutions reliably and with speed. In this article we explore one particular instance of these optimization paths: migrating from Vite to Esbuild. This document assumes a basic understanding of the React ecosystem, its build processes and the tools involved in it.
For a long-standing client, we have been developing a frontend project written in React Typescript, having now more than 1600 source files. We have been using Vite as the current bundling and transpiler tool, which proved to work amazingly for small projects. However, we've noticed that it started having issues with large amounts of files, being this project one of those instances. This is caused because the Vite development server uses the browser's native module support to enable hot module replacement, which means each source file is loaded individually, instead of the single bundled file that other build systems use. Although hot module replacement is a useful feature, this implementation makes its resource more intensive as the amount of files increases.
The first issue we've found is infinite loading: Vite dev server just hangs up when trying to load the local site. A fix for this issue is to increase the amount of file descriptors our OS allows, since there’s a bug by which the browser runs out of available file descriptors and never finishes loading the app. The second issue was sluggishness, since every time we navigated to a new page in our app, the files for every component present in it needed to be loaded. Another issue we had was that the hot module replacement worked sparingly, simple HTML changes would reload fine most of the time but changes to our clients or in our state management would only refresh correctly sometimes, and we would need to manually refresh the site. For that reason we considered the decrease in performance that came with the hot module replacement not to be worth it.
After testing different alternatives for build speed and bundle size (results below) we decided to move to Esbuild. Below you can find some speed comparison experiments we ran.
As you can see, Esbuild is 40x faster while keeping the total bundle size the same as Vite. So why haven't we used Esbuild from the beginning? Well, an important detail behind that decision is that it doesn't include a development server nor a way to generate HTML files from templates, which is required to generate the final, production-ready version of the app - since a typical index.html file is the one in charge to starting the app in production.
The first problem we tackled was the HTML generation. We first tried using already developed plugins for this, as we try not to reinvent the wheel wherever possible. However none of the two we tried worked as intended. One of them was no longer being developed and didn't support some of the features we needed, like inserting code conditionally to the HTML file based on environment variables, and the other one supported this feature but it had a bug which prevented it from working at all.
We identified the cause of that bug in the plugin's source code, but at this point we decided to develop our own plugin, since having full ownership of our build system allows us to customize it to our needs.
Esbuild's plugin API is extremely simple to use, to create a plugin we just had to create a function and add listeners that would be triggered before and after each build. Here's a simplified version of our HTML plugin.
Basically we delete the previous build's output before the new build starts, and copy the public files to the output folder and generate the index.html file using the lodash templates library.
After solving this first issue we started working on the biggest one, the development server. Again we began by trying the already available solutions, the obvious one being Esbuild's built-in watch+serve option, but at the moment of implementing it, it didn't support single page applications (it does now as of v0.18.16). We also tried Vercel's “serve” npm package, but as it didn't allow us to implement hot refresh, which is one of the features we wanted our dev server to have, we were giving up Vite's hot module replacement. In the end, we decided to expand upon our existing Esbuild plugin with a simple Express.js server which can be enabled with a config flag.
If the development server is enabled, we start and Express.js application that serves the build outputs and redirects all 404 errors to the index.html (to support SPAs). When the plugin is unmounted, we make sure to close the server to release the port for further builds.
To add support for hot refresh (reloading the page whenever a change is made) we use a combination of Esbuild's watch option, which rebuilds the app after a file is changed, and a websocket server plus a script inserted in the index.html which listens for changes and reloads the page. Here's how it works in the plugin:
And here's a simplified version of the client side script:
With this, we had all of the base functionality we needed to replace Vite. As a final detail, we added custom logging to our build, using libraries such as chalk, boxen, cli-spinner and more, which are great for building CLI tools.
Our build time ended up being around 700ms which is about 20 times faster than it was before, and our application runs just as fast locally as it does in production.
In this article we described a concrete case of development tool optimization - a task that we perform from time to time at Ensolvers to speed up the tech processes. In this case, we showed how we developed a custom solution for local React app development, tailored to our tech team needs.