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:


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(); 

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';


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():




export function get(selector, doc = document) 
  return doc.querySelector(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:




function timePad(n) 
  return String(n).padStart(2, '0');



export function formatHM(d = new Date()) 
  return timePad(d.getHours()) + ':' + timePad(d.getMinutes());



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:


function getAll(selector, doc = document) 
  return Array.from(doc.querySelectorAll(selector));



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());



var clock = getAll(".clock");
if (clock.length) 
  console.log("initializing clock");
  setInterval(() => 
    clock.forEach((c) => c.textContent = formatHMS());
  , 1e3);


(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:


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 './partials/variables.css';
@import './partials/elements.css';

variables.css defines default custom properties:


: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

*, *::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(../../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:


:root 
  --font-body: sans-serif;
  --color-fore: #fff;
  --color-back: #112;



*,
*::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;




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,:afterbox-sizing:border-box;font-weight:400;padding:0;margin:0bodyfont-family:var(--font-body);color:var(--color-fore);background:var(--color-back) url(/images/web.png) repeat;margin:1emheader h1font-size:2em;padding-left:1.5em;margin:.5em 0;background:url('data:image/svg+xml,<svg...></svg>') no-repeat.clockdisplay: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) 

  
  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 

  
  await buildCSS.watch();
  await buildJS.watch();

  
  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 =>  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.

Source link