esbuild is a fast bundler that can optimize JavaScript, TypeScript, JSX, and CSS code. This article will help you get up to speed with esbuild and show you how to create your own build system without other dependencies.
How Does esbuild Work?
Frameworks such as Vite have adopted esbuild, but you can use esbuild as a standalone tool in your own projects.
esbuild bundles JavaScript code into a single file in a similar way to bundlers such as Rollup. This is esbuild’s primary function, and it resolves modules, reports syntax issues, “tree-shakes” to remove unused functions, erases logging and debugger statements, minifies code, and provides source maps.
esbuild bundles CSS code into a single file. It’s not a full substitute for pre-processors such as Sass or PostCSS, but esbuild can handle partials, syntax issues, nesting, inline asset encoding, source maps, auto-prefixing, and minification. That may be all you need.
esbuild also provides a local development server with automatic bundling and hot-reloading, so there’s no need to refresh. It doesn’t have all the features offered by Browsersync, but it’s good enough for most cases.
The code below will help you understand esbuild concepts so you can investigate further configuration opportunities for your projects.
Why Bundle?
Bundling code into a single file offers various benefits. Here are some of them:
- you can develop smaller, self-contained source files which are easier to maintain
- you can lint, prettify, and syntax-check code during the bundling process
- the bundler can remove unused functions — known as tree-shaking
- you can bundle alternative versions of the same code, and create targets for older browsers, Node.js, Deno, and so on
- single files load faster than multiple files and the browser doesn’t require ES module support
- production-level bundling can improve performance by minifying code and removing logging and debugging statements
Why Use esbuild?
Unlike JavaScript bundlers, esbuild is a compiled Go executable which implements heavy parallel processing. It’s quick and up to one hundred times faster than Rollup, Parcel, or Webpack. It could save weeks of development time over the lifetime of a project.
In addition, esbuild also offers:
- built-in bundling and compilation for JavaScript, TypeScript, JSX, and CSS
- command-line, JavaScript, and Go configuration APIs
- support for ES modules and CommonJS
- a local development server with watch mode and live reloading
- plugins to add further functionality
- comprehensive documentation and an online experimentation tool
Why Avoid esbuild?
At the time of writing, esbuild has reached version 0.18. It’s reliable but still a beta product.
esbuild is frequently updated and options may change between versions. The documentation recommends you stick with a specific version. You can update it, but you may need to migrate your configuration files and delve into new documentation to discover breaking changes.
Note also that esbuild doesn’t perform TypeScript type checking, so you’ll still need to run tsc -noEmit
.
Super-quick Start
If necessary, create a new Node.js project with npm init
, then install esbuild locally as a development dependency:
npm install esbuild --save-dev --save-exact
The installation requires around 9MB. Check it works by running this command to see the installed version:
./node_modules/.bin/esbuild --version
Or run this command to view CLI help:
./node_modules/.bin/esbuild --help
Use the CLI API to bundle an entry script (myapp.js
) and all its imported modules into a single file named bundle.js
. esbuild will output a file using the default, browser-targeted, immediately-invoked function expression (IIFE) format:
./node_modules/.bin/esbuild myapp.js --bundle --outfile=bundle.js
You can install esbuild in other ways if you’re not using Node.js.
Example Project
Download the example files and an esbuild configuration from Github. It’s a Node.js project, so install the single esbuild dependency with:
npm install
Build the source files in src
to a build
directory and start a development server with:
npm start
Now navigate to localhost:8000
in your browser to view a web page showing a real-time clock. When you update any CSS file in src/css/
or src/css/partials
, esbuild will re-bundle the code and live reload the styles.
Press Ctrl|Cmd + Ctrl|Cmd to stop the server.
Create a production build for deployment using:
npm run build
Examine the CSS and JavaScript files in the build
directory to see the minified versions without source maps.
Project Overview
The real-time clock page is constructed in a build
directory using source files from src
.
The package.json
file defines five npm
scripts. The first deletes the build
directory:
"clean": "rm -rf ./build",
Before any bundling occurs, an init
script runs clean
, creates a new build
directory and copies:
- a static HTML file from
src/html/index.html
tobuild/index.html
- static images from
src/images/
tobuild/images/
"init": "npm run clean && mkdir ./build && cp ./src/html/* ./build/ && cp -r ./src/images ./build",
An esbuild.config.js
file controls the esbuild bundling process using the JavaScript API. This is easier to manage than passing options to the CLI API, which can become unwieldy. An npm
bundle
script runs init
followed by node ./esbuild.config.js
:
"bundle": "npm run init && node ./esbuild.config.js",
The last two npm
scripts run bundle
with either a production
or development
parameter passed to ./esbuild.config.js
to control the build:
"build": "npm run bundle -- production",
"start": "npm run bundle -- development"
When ./esbuild.config.js
runs, it determines whether it should create minified production
files (the default) or development
files with automatic updates, source maps, and a live-reloading server. In both cases, esbuild bundles:
- the entry CSS file
src/css/main.css
tobuild/css/main.css
- the entry JavaScript file
scr/js/main.js
tobuild/js/main.js
Configuring esbuild
package.json
has a "type"
of "module"
so all .js
files can use ES Modules. The esbuild.config.js
script imports esbuild
and sets productionMode
to true
when bundling for production or false
when bundling for development:
import { argv } from 'node:process';
import * as esbuild from 'esbuild';
const
productionMode = ('development' !== (argv[2] || process.env.NODE_ENV)),
target = 'chrome100,firefox100,safari15'.split(',');
console.log(`${ productionMode ? 'production' : 'development' } build`);
Bundle target
Note that the target variable defines an array of browsers and version numbers to use in the configuration. This affects the bundled output and changes the syntax to support specific platforms. For example, esbuild can:
- expand native CSS nesting into full selectors (nesting would remain if
"Chrome115"
was the only target) - add CSS vendor-prefixed properties where necessary
- polyfill the
??
nullish coalescing operator - remove
#
from private class fields
As well as browsers, you can also target node
and es
versions such as es2020
and esnext
(the latest JS and CSS features).
JavaScript Bundling
The simplest API to create a bundle:
await esbuild.build({
entryPoints: ['myapp.js'],
bundle: true
outfile: 'bundle.js'
});
This replicates the CLI command used above:
./node_modules/.bin/esbuild myapp.js --bundle --outfile=bundle.js
The example project uses more advanced options such as file watching. This requires a long-running build context which sets the configuration:
// bundle JS
const buildJS = await esbuild.context({
entryPoints: [ './src/js/main.js' ],
format: 'esm',
bundle: true,
target,
drop: productionMode ? ['debugger', 'console'] : [],
logLevel: productionMode ? 'error' : 'info',
minify: productionMode,
sourcemap: !productionMode && 'linked',
outdir: './build/js'
});
esbuild offers dozens of configuration options. Here’s a rundown of the ones used here:
entryPoints
defines an array of file entry points for bundling. The example project has one script at./src/js/main.js
.format
sets the output format. The example usesesm
, but you can optionally setiife
for older browsers orcommonjs
for Node.js.bundle
set totrue
inlines imported modules into the output file.target
is the array of target browsers defined above.drop
is an array ofconsole
and/ordebugger
statements to remove. In this case, production builds remove both and development builds retain them.logLevel
defines the logging verbosity. The example above shows errors during production builds and more verbose information messages during development builds.minify
reduces the code size by removing comments and whitespace and renaming variables and functions where possible. The example project minifies during production builds but prettifies code during development builds.sourcemap
set tolinked
(in development mode only) generates a linked source map in a.map
file so the original source file and line is available in browser developer tools. You can also setinline
to include the source map inside the bundled file,both
to create both, orexternal
to generate a.map
file without a link from the bundled JavaScript.outdir
defines the bundled file output directory.
Call the context object’s rebuild()
method to run the build once — typically for a production build:
await buildJS.rebuild();
buildJS.dispose(); // free up resources
Call the context object’s watch()
method to keep running and automatically re-build when watched files change:
await buildJS.watch();
The context object ensures subsequent builds are processed incrementally and that they reuse work from previous builds to improve performance.
JavaScript input and output files
The entry src/js/main.js
file imports dom.js
and time.js
modules from the lib
sub-folder. It finds all elements with a class of clock
and sets their text content to the current time every second:
import * as dom from './lib/dom.js';
import { formatHMS } from './lib/time.js';
// get clock element
const clock = dom.getAll('.clock');
if (clock.length) {
console.log('initializing clock');
setInterval(() => {
clock.forEach(c => c.textContent = formatHMS());
}, 1000);
}
dom.js
exports two functions. main.js
imports both but only uses getAll()
:
// DOM libary
// fetch first node from selector
export function get(selector, doc = document) {
return doc.querySelector(selector);
}
// fetch all nodes from selector
export function getAll(selector, doc = document) {
return Array.from(doc.querySelectorAll(selector));
}
time.js
exports two functions. main.js
imports formatHMS()
, but that uses the other functions in the module:
// time library
// return 2-digit value
function timePad(n) {
return String(n).padStart(2, '0');
}
// return time in HH:MM format
export function formatHM(d = new Date()) {
return timePad(d.getHours()) + ':' + timePad(d.getMinutes());
}
// return time in HH:MM:SS format
export function formatHMS(d = new Date()) {
return formatHM(d) + ':' + timePad(d.getSeconds());
}
The resulting development bundle removes (tree shakes) get()
from dom.js
but includes all the time.js
functions. A source map is also generated:
// src/js/lib/dom.js
function getAll(selector, doc = document) {
return Array.from(doc.querySelectorAll(selector));
}
// src/js/lib/time.js
function timePad(n) {
return String(n).padStart(2, "0");
}
function formatHM(d = new Date()) {
return timePad(d.getHours()) + ":" + timePad(d.getMinutes());
}
function formatHMS(d = new Date()) {
return formatHM(d) + ":" + timePad(d.getSeconds());
}
// src/js/main.js
var clock = getAll(".clock");
if (clock.length) {
console.log("initializing clock");
setInterval(() => {
clock.forEach((c) => c.textContent = formatHMS());
}, 1e3);
}
//# sourceMappingURL=main.js.map
(Note that esbuild can rewrite let
and const
to var
for correctness and speed.)
The resulting production bundle minifies the code to 322 characters:
function o(t,c=document){return Array.from(c.querySelectorAll(t))}function e(t){return String(t).padStart(2,"0")}function l(t=new Date){return e(t.getHours())+":"+e(t.getMinutes())}function r(t=new Date){return l(t)+":"+e(t.getSeconds())}var n=o(".clock");n.length&&setInterval(()=>{n.forEach(t=>t.textContent=r())},1e3);
CSS Bundling
CSS bundling in the example project uses a similar context object to JavaScript above:
// bundle CSS
const buildCSS = await esbuild.context({
entryPoints: [ './src/css/main.css' ],
bundle: true,
target,
external: ['/images/*'],
loader: {
'.png': 'file',
'.jpg': 'file',
'.svg': 'dataurl'
},
logLevel: productionMode ? 'error' : 'info',
minify: productionMode,
sourcemap: !productionMode && 'linked',
outdir: './build/css'
});
It defines an external
option as an array of files and paths to exclude from the build. In the example project, files in the src/images/
directory are copied to the build
directory so the HTML, CSS, or JavaScript can reference them directly. If this was not set, esbuild would copy files to the output build/css/
directory when using them in background-image
or similar properties.
The loader
option changes how esbuild handles an imported file that’s not referenced as an external
asset. In this example:
- SVG images become inlined as data URIs
- PNG and JPG images are copied to the
build/css/
directory and referenced as files
CSS input and output files
The entry src/css/main.css
file imports variables.css
and elements.css
from the partials
sub-folder:
/* import */
@import './partials/variables.css';
@import './partials/elements.css';
variables.css
defines default custom properties:
/* primary variables */
:root {
--font-body: sans-serif;
--color-fore: #fff;
--color-back: #112;
}
elements.css
defines all styles. Note:
- the
body
has a background image loaded from the externalimages
directory - the
h1
is nested insideheader
- the
h1
has a background SVG which will be inlined - the target browsers require no vendor prefixes
/* element styling */
*, *::before, ::after {
box-sizing: border-box;
font-weight: normal;
padding: 0;
margin: 0;
}
body {
font-family: var(--font-body);
color: var(--color-fore);
background: var(--color-back) url(/images/web.png) repeat;
margin: 1em;
}
/* nested elements with inline icon */
header {
& h1 {
font-size: 2em;
padding-left: 1.5em;
margin: 0.5em 0;
background: url(../../icons/clock.svg) no-repeat;
}
}
.clock {
display: block;
font-size: 5em;
text-align: center;
font-variant-numeric: tabular-nums;
}
The resulting development bundle expands the nested syntax, inlines the SVG, and generates a source map:
/* src/css/partials/variables.css */
:root {
--font-body: sans-serif;
--color-fore: #fff;
--color-back: #112;
}
/* src/css/partials/elements.css */
*,
*::before,
::after {
box-sizing: border-box;
font-weight: normal;
padding: 0;
margin: 0;
}
body {
font-family: var(--font-body);
color: var(--color-fore);
background: var(--color-back) url(/images/web.png) repeat;
margin: 1em;
}
header h1 {
font-size: 2em;
padding-left: 1.5em;
margin: 0.5em 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>*{fill:none;stroke:%23fff;stroke-width:1.5;stroke-miterlimit:10}<\/style></defs><circle cx="12" cy="12" r="10.5"></circle><circle cx="12" cy="12" r="0.95"></circle><polyline points="12 4.36 12 12 16.77 16.77"></polyline></svg>') no-repeat;
}
.clock {
display: block;
font-size: 5em;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* src/css/main.css */
/*# sourceMappingURL=main.css.map */
The resulting production bundle minifies the code to 764 characters (the SVG is omitted here):
:root{--font-body: sans-serif;--color-fore: #fff;--color-back: #112}*,*:before,:after{box-sizing:border-box;font-weight:400;padding:0;margin:0}body{font-family:var(--font-body);color:var(--color-fore);background:var(--color-back) url(/images/web.png) repeat;margin:1em}header h1{font-size:2em;padding-left:1.5em;margin:.5em 0;background:url('data:image/svg+xml,<svg...></svg>') no-repeat}.clock{display:block;font-size:5em;text-align:center;font-variant-numeric:tabular-nums}
Watching, Rebuilding, and Serving
The remainder of the esbuild.config.js
script bundles once for production builds before terminating:
if (productionMode) {
// single production build
await buildCSS.rebuild();
buildCSS.dispose();
await buildJS.rebuild();
buildJS.dispose();
}
During development builds, the script keeps running, watches for file changes, and automatically bundles again. The buildCSS
context launches a development web server with build/
as the root directory:
else {
// watch for file changes
await buildCSS.watch();
await buildJS.watch();
// development server
await buildCSS.serve({
servedir: './build'
});
}
Start the development build with:
npm start
Then navigate to localhost:8000
to view the page.
Unlike Browsersync, you’ll need to add your own code to development pages to live reload. When changes occur, esbuild sends information about the update via a server-sent event. The simplest option is to fully reload the page when any change occurs:
new EventSource('/esbuild').addEventListener('change', () => location.reload());
The example project uses the CSS context object to create the server. That’s because I prefer to manually refresh JavaScript changes — and because I couldn’t find a way for esbuild to send an event for both CSS and JS updates! The HTML page includes the following script to replace updated CSS files without a full page refresh (a hot-reload):
<script type="module">
// esbuild server-sent event - live reload CSS
new EventSource('/esbuild').addEventListener('change', e => {
const { added, removed, updated } = JSON.parse(e.data);
// reload when CSS files are added or removed
if (added.length || removed.length) {
location.reload();
return;
}
// replace updated CSS files
Array.from(document.getElementsByTagName('link')).forEach(link => {
const url = new URL(link.href), path = url.pathname;
if (updated.includes(path) && url.host === location.host) {
const css = link.cloneNode();
css.onload = () => link.remove();
css.href = `${ path }?${ +new Date() }`;
link.after(css);
}
})
});
Note that esbuild doesn’t currently support JavaScript hot-reloading — not that I’d trust it anyway!
Summary
With a little configuration, esbuild could be enough to handle all your project’s development and production build requirements.
There’s a comprehensive set of plugins should you require more advanced functionality. Be aware these often include Sass, PostCSS, or similar build tools, so they effectively use esbuild as a task runner. You can always create your own plugins if you need more lightweight, custom options.
I’ve been using esbuild for a year. The speed is astounding compared to similar bundlers, and new features appear frequently. The only minor downside is breaking changes that incur maintenance.
esbuild doesn’t claim to be a unified, all-in-one build tool, but it’s probably closer to that goal than Rome.
Frequently Asked Questions (FAQs) about ESBuild
What Makes ESBuild Faster Than Other Bundlers?
ESBuild’s speed is primarily due to its architecture. It is written in Go, a statically typed, compiled language known for its efficiency and performance. This allows ESBuild to leverage the power of multi-core processors and parallelize tasks, resulting in faster build times. Additionally, ESBuild minimizes the use of abstract syntax trees (ASTs), which are typically resource-intensive. Instead, it uses a binary AST format for JavaScript parsing, which is faster and more memory-efficient.
How Do I Install ESBuild?
ESBuild can be installed via npm, the Node.js package manager. You can install it globally on your system by running the command npm install -g esbuild
. Alternatively, you can add it as a development dependency in your project with the command npm install --save-dev esbuild
.
Can ESBuild Handle CSS and Images?
Yes, ESBuild can handle CSS and image files. It has built-in loaders for these file types, which means you don’t need to install additional plugins or loaders. You can specify the loader in the build options, for example, { loader: 'css' }
for CSS files and { loader: 'file' }
for image files.
How Do I Use ESBuild with React?
ESBuild can be used with React by specifying the JSX factory and JSX fragment in the build options. For example, you can use { jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment' }
. You also need to ensure that React is imported in your JSX files.
Does ESBuild Support TypeScript?
Yes, ESBuild has built-in support for TypeScript. It can directly compile TypeScript to JavaScript without the need for an additional TypeScript compiler. You can specify the loader as { loader: 'ts' }
in the build options for TypeScript files.
How Do I Configure ESBuild for Production Builds?
ESBuild can be configured for production builds by setting the minify
and sourcemap
options in the build configuration. For example, { minify: true, sourcemap: true }
. This will minify the output and generate source maps, which are essential for debugging in production.
Can ESBuild Bundle Node.js Applications?
Yes, ESBuild can bundle Node.js applications. You can specify the platform as ‘node’ in the build options, for example, { platform: 'node' }
. This will ensure that Node.js-specific modules and APIs are correctly bundled.
Does ESBuild Support Code Splitting?
Yes, ESBuild supports code splitting, which is a technique to split your code into various bundles that can be loaded on demand or in parallel. You can enable code splitting by setting the splitting
and format
options in the build configuration, for example, { splitting: true, format: 'esm' }
.
How Do I Use ESBuild with Babel?
While ESBuild is designed to be a faster alternative to Babel, you can still use them together if you need Babel’s advanced transformations. You can use Babel as a loader in ESBuild by installing the esbuild-loader
package and configuring it in your build options.
Can ESBuild Replace Webpack?
ESBuild can replace Webpack in many scenarios due to its speed and simplicity. However, Webpack has more features and plugins, and it’s more configurable. If you need advanced features like hot module replacement or complex code splitting, you might still need Webpack. But for simple projects, ESBuild can be a faster and simpler alternative.
Craig is a freelance UK web consultant who built his first page for IE2.0 in 1995. Since that time he's been advocating standards, accessibility, and best-practice HTML5 techniques. He's created enterprise specifications, websites and online applications for companies and organisations including the UK Parliament, the European Parliament, the Department of Energy & Climate Change, Microsoft, and more. He's written more than 1,000 articles for SitePoint and you can find him @craigbuckler.