Using esbuild As Your New Bundler

Published on March 8, 2021


Table of Contents

Introduction

You might have heard about esbuild before even reading this post. If you did, that's great! But if you never heard about it before this, or at least you haven't tried it, then you should!

You can make esbuild to become either a bundler, or just simply use it as a compiler in replacement of babel.

⚠️ esbuild doesn't fully support es5 syntax yet, you might want to reconsider about shipping stuffs with esbuild to old browsers. You can read about it in this thread.

Replacing Babel

We all know about babel, how powerful babel is, and how babel has helped us building great stuffs all around the web. But in for large scale project, you might have experienced that the build time takes very long! This might become one of the biggest productivity blocker.

Babel simply has more overhead cost than esbuild. Even if you cache the loader, esbuild is much much faster. Although, esbuild even put restriction for when writing plugin, so you might want to know this before really replacing babel, especially if you depend on specific babel plugins that is not present in esbuild.

Obtaining list of files

Let us talk about how to replace babel with esbuild. But before this, you should know that babel can simply take a folder as entry points, but in esbuild you need to specify all the file paths that you are going to compile as an array of paths.

I suggest you to start from using Typescript because esbuild supports typescript natively, without adding new plugin. Therefore, it's safe to assume that it doesn't have any performance impact when you are compiling typescript or normal javascript files, unlike Babel.

If you are using typescript, you can get the list of needed files through tsConfig.fileNames.

getTSConfig.js

const ts = require('typescript');
function getTSConfig(configPath = 'tsconfig.json') {
const tsConfigFile = ts.findConfigFile(process.cwd(), ts.sys.fileExists, configPath);
if (!tsConfigFile) {
throw new Error(`tsconfig.json does not exist in the current directory: ${process.cwd()}`);
}
const configFile = ts.readConfigFile(tsConfigFile, ts.sys.readFile);
if (configFile.error) {
throw new Error(`Cannot read TS configuration file from ${process.cwd()}: ${configFile.error}`);
}
const tsConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, process.cwd());
return { tsConfig, tsConfigFile };
}

Adding esbuild

Now, let's pair it together with esbuild.

The example here only shows how to rely on typescript API to discover the files. In case you are not using typescript in your project, there is a gist that lets you list all the files path in a folder recursively.

But don't forget to only include .js, .jsx, .ts, or .tsx files!

getTSConfig.js
build.js

const esbuild = require('esbuild');
const getTSConfig = require('./getTSConfig');
async function build() {
const { tsConfig, tsConfigFile } = getTSConfig();
await esbuild.build({
color: true,
entryPoints: tsConfig.fileNames,
outdir: OUT_DIR,
loader: {
'.js': 'jsx', // if you are also compiling react files
},
format: 'esm', // can be also 'cjs'
target: 'es2015',
minify: false,
bundle: false,
tsconfig: tsConfigFile,
});
}

Compiling other file assets

Using the configuration above, esbuild will skip to compile every files with extension that typescript unable to read. That means, if you are importing images in your js/ts files, they won't be exported in the out directory. This could lead to build time error.

So here is a piece of code you might want to consider adding after the build process is finished.

If you are curious about the timing, try logging the time before & after each processes (build & copy).

getTSConfig.js
build.js

const cpy = require('cpy');
const esbuild = require('esbuild');
const getTSConfig = require('./getTSConfig');
async function build() {
const { tsConfig, tsConfigFile } = getTSConfig();
await esbuild.build({
color: true,
entryPoints: tsConfig.fileNames,
outdir: OUT_DIR,
loader: {
'.js': 'jsx', // if you are also compiling react files
},
format: 'esm', // can be also 'cjs'
target: 'es2015',
minify: false,
bundle: false,
tsconfig: tsConfigFile,
});
const relativeOutDir = path.relative(SRC_DIR, OUT_DIR);
await cpy(['**', `!**/*.{js,ts,jx,tsx}`], relativeOutDir, {
cwd: SRC_DIR,
parents: true,
});
}

Obtaining list of files

Let us talk about how to replace babel with esbuild. But before this, you should know that babel can simply take a folder as entry points, but in esbuild you need to specify all the file paths that you are going to compile as an array of paths.

I suggest you to start from using Typescript because esbuild supports typescript natively, without adding new plugin. Therefore, it's safe to assume that it doesn't have any performance impact when you are compiling typescript or normal javascript files, unlike Babel.

If you are using typescript, you can get the list of needed files through tsConfig.fileNames.

Adding esbuild

Now, let's pair it together with esbuild.

The example here only shows how to rely on typescript API to discover the files. In case you are not using typescript in your project, there is a gist that lets you list all the files path in a folder recursively.

But don't forget to only include .js, .jsx, .ts, or .tsx files!

Compiling other file assets

Using the configuration above, esbuild will skip to compile every files with extension that typescript unable to read. That means, if you are importing images in your js/ts files, they won't be exported in the out directory. This could lead to build time error.

So here is a piece of code you might want to consider adding after the build process is finished.

If you are curious about the timing, try logging the time before & after each processes (build & copy).

getTSConfig.js
ExpandClose

const ts = require('typescript');
function getTSConfig(configPath = 'tsconfig.json') {
const tsConfigFile = ts.findConfigFile(process.cwd(), ts.sys.fileExists, configPath);
if (!tsConfigFile) {
throw new Error(`tsconfig.json does not exist in the current directory: ${process.cwd()}`);
}
const configFile = ts.readConfigFile(tsConfigFile, ts.sys.readFile);
if (configFile.error) {
throw new Error(`Cannot read TS configuration file from ${process.cwd()}: ${configFile.error}`);
}
const tsConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, process.cwd());
return { tsConfig, tsConfigFile };
}

Using with existing bundler

If you are using webpack, I suggest you to have a look into esbuild-loader.

If you are using rollup, there is also existing plugin to pair with esbuild. You can refer to the full configuration I have tried before writing this post.

There is not much to be explained, but simply follow the instructions given in their README.

Future

Esbuild is very promising. The development is very active, there is always a new patch coming in every 1 or 2 working days.

Esbuild has been adopted by Vite.js in development mode only, and also by Remix as well.

Conclusion

It is not easy to fully migrate from existing build tools, there are also many unexplained limitations or edge cases of esbuild in this blog. Although, you may find several testimonies about the significant speed improvements here.

If you previously relied on babel to help optimize your app, this could be a challenge for migrating your existing app. For example you might have been using babel-plugin-lodash to help transforming lodash imports in order to avoid bloated bundle sizes. Then you might need to recheck your app vendor's size. But here is the esbuild plugin to help you get similar behaviour.

If you are not convinced yet, just give it a try 😉