An Introduction to the esbuild Bundler

Share this article

An Introduction to the esbuild Bundler

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.

Table of Contents
  1. How Does esbuild Work?
  2. Why Bundle?
  3. Why Use esbuild?
  4. Why Avoid esbuild?
  5. Super-quick Start
  6. Example Project
  7. Project Overview
  8. Configuring esbuild
  9. JavaScript Bundling
  10. CSS Bundling
  11. Watching, Rebuilding, and Serving
  12. Summary

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.

esbuild example clock project

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:

  1. a static HTML file from src/html/index.html to build/index.html
  2. static images from src/images/ to build/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 to build/css/main.css
  • the entry JavaScript file scr/js/main.js to build/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 uses esm, but you can optionally set iife for older browsers or commonjs for Node.js.

  • bundle set to true inlines imported modules into the output file.

  • target is the array of target browsers defined above.

  • drop is an array of console and/or debugger 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 to linked (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 set inline to include the source map inside the bundled file, both to create both, or external 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 external images directory
  • the h1 is nested inside header
  • 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 BucklerCraig Buckler
View Author

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.

build toolsbundleresbuild
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week