← Back to DevBytes

ESBuild from Beginner to Expert: A Learning Path

What is ESBuild?

ESBuild is an extremely fast JavaScript bundler and minifier written in Go. Unlike most build tools in the JavaScript ecosystem that are written in JavaScript or TypeScript themselves, ESBuild leverages Go's compiled performance to achieve bundling speeds that are 10–100x faster than traditional tools like webpack, Rollup, or Parcel. It was created by Evan Wallace (co-creator of Figma) and has rapidly gained adoption across the frontend tooling landscape.

At its core, ESBuild provides:

Key Characteristics

ESBuild is designed with a specific philosophy: it prioritizes speed and simplicity over exhaustive feature coverage. It deliberately avoids some complex transformations like AST-level code manipulation for bundle splitting, instead focusing on providing a rock-solid foundation that other tools can build upon. This means ESBuild excels as the core compiler in larger toolchains — it's the engine inside Vite, SvelteKit, and many modern frameworks.

Why ESBuild Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The JavaScript ecosystem has long struggled with slow build times. As projects grow to thousands of modules, traditional JavaScript-based bundlers can take minutes to produce a production build. ESBuild fundamentally changes this equation by bringing compilation times down to milliseconds or seconds. This speed unlocks new workflows: instant dev server restarts, rapid CI/CD pipelines, and a dramatically improved developer experience.

Performance Comparison

In benchmark tests, ESBuild consistently outperforms other bundlers by staggering margins. A typical project with 1,000+ modules might take webpack 45 seconds to bundle, while ESBuild completes the same task in under 500 milliseconds. This isn't just a marginal improvement — it's a paradigm shift that eliminates the "waiting for builds" friction entirely.

Why Go?

Go is a compiled systems language with excellent concurrency primitives. ESBuild uses goroutines to parallelize parsing, resolving, linking, and code generation across all available CPU cores. Additionally, Go's memory management model avoids the garbage collection pauses that can plague large JavaScript applications. The entire ESBuild codebase is distributed as a single self-contained binary with zero runtime dependencies.

Installation and Your First Build

ESBuild can be installed globally via npm, or you can download a standalone binary. The npm package wraps the native Go binary, so there's no compilation step required on your machine.

# Install globally via npm
npm install -g esbuild

# Or install as a project dependency
npm install --save-dev esbuild

# Verify installation
esbuild --version

Let's create a simple project to test ESBuild. Create two files:

// file: src/index.js
import { greet } from './utils';

const message = greet('World');
console.log(message);
// file: src/utils.js
export function greet(name) {
  return `Hello, ${name}! Welcome to ESBuild.`;
}

Now bundle these files into a single output:

esbuild src/index.js --bundle --outfile=dist/bundle.js --minify

After running this command, you'll find dist/bundle.js containing the bundled and minified code. The --bundle flag tells ESBuild to follow all imports and concatenate them into a single file. The --minify flag applies aggressive minification.

Understanding ESBuild's Core Concepts

Entry Points

An entry point is the root module where ESBuild begins its traversal. You can specify multiple entry points, and ESBuild will analyze the dependency graph for each independently. Multiple entry points are useful for multi-page applications or libraries with several export targets.

# Single entry point
esbuild src/app.js --bundle --outfile=dist/app.js

# Multiple entry points
esbuild src/page1.js src/page2.js --bundle --outdir=dist/

Output Control

ESBuild provides two output modes: --outfile for single-file output (typically used with --bundle) and --outdir for multi-entry scenarios where each entry point produces a corresponding output file. When using --outdir, ESBuild preserves the relative directory structure from the entry points.

# Single output file (requires --bundle)
esbuild src/index.js --bundle --outfile=dist/bundle.js

# Directory output (preserves structure)
esbuild src/**/*.js --outdir=dist/

Bundling vs. Transpiling

Without the --bundle flag, ESBuild acts purely as a transpiler — it converts modern syntax (TypeScript, JSX, ES modules) into browser-compatible JavaScript but does not follow imports. With --bundle, it resolves the entire dependency graph and produces a self-contained output.

# Transpile only (no import resolution)
esbuild src/file.ts --outfile=dist/file.js

# Bundle with dependency resolution
esbuild src/index.ts --bundle --outfile=dist/bundle.js

Using ESBuild via the Command Line

The CLI is the fastest way to get started. Here's a comprehensive reference of the most important flags:

# Complete build command example
esbuild src/app.ts \
  --bundle \
  --minify \
  --sourcemap \
  --target=es2020 \
  --platform=browser \
  --format=iife \
  --global-name=MyApp \
  --outfile=dist/app.min.js \
  --define:process.env.NODE_ENV=\"production\" \
  --loader:.png=dataurl \
  --external:react \
  --metafile=meta.json \
  --analyze

Essential CLI Flags Explained

The JavaScript API: Programmatic Control

For build scripts, CI/CD pipelines, or custom toolchains, the JavaScript API gives you full control. It mirrors the CLI options but allows dynamic configuration and integration with Node.js workflows.

// file: build.js
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  minify: true,
  sourcemap: true,
  target: ['es2020'],
  platform: 'browser',
  format: 'esm',
  outfile: 'dist/bundle.js',
  define: {
    'process.env.NODE_ENV': '"production"'
  },
  external: ['react', 'react-dom'],
  metafile: true,
  logLevel: 'info'
}).then(result => {
  console.log('Build succeeded!');
  // result.metafile contains the dependency graph
  console.log(result.metafile);
}).catch(() => process.exit(1));

Build vs. Transform API

ESBuild offers two distinct APIs. The build API handles full bundling with file system interaction. The transform API works purely in-memory for single-file transformations — perfect for tools that need inline compilation.

// The transform API for in-memory processing
const result = await esbuild.transform(
  'const x: number = 42; console.log(x);',
  {
    loader: 'ts',
    target: 'es2015',
    sourcemap: true
  }
);

console.log(result.code);     // Transpiled JavaScript
console.log(result.map);      // Source map (JSON string)
console.log(result.warnings); // Any warnings

Deep Dive: Build Options and Strategies

Target Environments

The target option controls the syntax and features ESBuild can use. Setting es2015 means ESBuild will downlevel arrow functions, template literals, and other ES6+ syntax. You can specify multiple targets for differential serving.

// Modern and legacy dual targets
await esbuild.build({
  entryPoints: ['src/app.js'],
  bundle: true,
  target: ['es2020', 'es2015'],
  outfile: 'dist/app.js'
});

Platform-Specific Behavior

The platform option adjusts several defaults simultaneously. platform: 'browser' automatically treats fs, path, and other Node.js built-ins as external. platform: 'node' keeps them bundled and uses CommonJS-compatible output.

// Node.js library bundle
await esbuild.build({
  entryPoints: ['src/lib.ts'],
  bundle: true,
  platform: 'node',
  target: ['node16'],
  format: 'cjs',
  outfile: 'dist/lib.js',
  external: ['express']
});

Output Formats in Detail

ESBuild supports three output formats:

// IIFE format with global name
await esbuild.build({
  entryPoints: ['src/widget.js'],
  bundle: true,
  format: 'iife',
  globalName: 'MyWidget',
  minify: true,
  outfile: 'dist/widget.min.js'
});

// ESM format for modern consumption
await esbuild.build({
  entryPoints: ['src/module.js'],
  bundle: true,
  format: 'esm',
  outfile: 'dist/module.mjs'
});

Working with TypeScript and JSX

ESBuild has first-class TypeScript and JSX support built directly into the core. There's no need for tsc or Babel — ESBuild strips type annotations and compiles JSX syntax natively. However, it does not perform type checking. For type safety, you should run tsc --noEmit separately in your lint or CI pipeline.

// TypeScript file: src/components/Button.tsx
interface ButtonProps {
  label: string;
  onClick: () => void;
}

export function Button({ label, onClick }: ButtonProps) {
  return (
    
  );
}
// Build configuration for TSX
await esbuild.build({
  entryPoints: ['src/components/Button.tsx'],
  bundle: true,
  loader: { '.tsx': 'tsx' },
  jsx: 'automatic',  // React 17+ JSX runtime
  jsxImportSource: 'react',
  minify: true,
  outfile: 'dist/components.js'
});

JSX Modes

ESBuild supports three JSX modes:

// Different JSX configurations
// Classic React
esbuild.build({
  entryPoints: ['src/app.jsx'],
  jsx: 'transform',
  jsxFactory: 'React.createElement',
  jsxFragment: 'React.Fragment'
});

// Modern React with automatic runtime
esbuild.build({
  entryPoints: ['src/app.tsx'],
  jsx: 'automatic',
  jsxImportSource: 'react'
});

// Preact
esbuild.build({
  entryPoints: ['src/app.tsx'],
  jsx: 'automatic',
  jsxImportSource: 'preact'
});

CSS Bundling and Code Splitting

ESBuild supports CSS as a first-class content type. You can bundle CSS files, resolve @import statements, and handle CSS from JavaScript imports. ESBuild can even output CSS files separately from JavaScript bundles.

// CSS entry point: src/styles/main.css
@import './reset.css';
@import './variables.css';

body {
  font-family: sans-serif;
  background: var(--bg-color);
}
// Build CSS independently
await esbuild.build({
  entryPoints: ['src/styles/main.css'],
  bundle: true,
  minify: true,
  outfile: 'dist/styles.min.css'
});

CSS Code Splitting

When you import CSS from JavaScript modules, ESBuild can extract those imports into separate CSS files using code splitting. This requires using the --splitting flag with ESM format output.

// JavaScript file that imports CSS
// file: src/components/Modal.ts
import './modal.css';

export function showModal() {
  // Modal logic
}
// Build with CSS splitting
await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist/',
  loader: {
    '.css': 'css'
  }
});
// This produces dist/index.js and dist/chunk-abc123.css

Important Limitation on Code Splitting

ESBuild's code splitting is intentionally simple. It does not perform automatic dynamic import splitting like webpack's SplitChunksPlugin. ESBuild only splits at explicit dynamic import() boundaries. For complex chunking strategies, tools like Vite build on top of ESBuild's core while handling advanced splitting themselves.

// Explicit dynamic import triggers splitting
// file: src/router.js
async function loadDashboard() {
  const module = await import('./dashboard.js');
  return module.render();
}

// ESBuild will split this into a separate chunk

Loaders and Content Types

Loaders tell ESBuild how to interpret different file extensions. The built-in loaders include js, jsx, ts, tsx, css, json, text, binary, base64, dataurl, and file. You can override the default mapping or add custom extensions.

// Custom loader configuration
await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  loader: {
    '.png': 'dataurl',   // Embed images as data URLs
    '.svg': 'text',      // Import SVG as string
    '.wasm': 'binary',   // Include WebAssembly as buffer
    '.md': 'text'        // Markdown files as string
  },
  outfile: 'dist/bundle.js'
});

Loader Reference

ESBuild's Plugin System

Plugins are the primary extension mechanism in ESBuild. They allow you to intercept the build process at specific points (called "build hooks") to modify file contents, resolve imports differently, or inject virtual modules. Plugins run in the JavaScript/Node.js host process and communicate with the Go core via a high-performance protocol.

Plugin Architecture

A plugin is an object with a name property and a setup function. The setup function receives a build object that exposes methods for registering hooks:

// Basic plugin structure
const myPlugin = {
  name: 'my-plugin',
  setup(build) {
    // Register hooks here
    build.onResolve({ filter: /^my-virtual-module$/ }, args => {
      return {
        path: args.path,
        namespace: 'virtual'
      };
    });

    build.onLoad({ filter: /.*/, namespace: 'virtual' }, args => {
      return {
        contents: 'export default "Hello from virtual module!"',
        loader: 'js'
      };
    });
  }
};

await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  plugins: [myPlugin],
  outfile: 'dist/bundle.js'
});

Plugin Hooks Reference

Real-World Plugin: Environment Variables

// Plugin that replaces process.env variables
const envPlugin = {
  name: 'env',
  setup(build) {
    // Intercept all paths (broad filter)
    build.onResolve({ filter: /.*/ }, args => {
      // We can return nothing to let ESBuild continue normally
      // or return a path to override resolution
      return {};
    });

    build.onLoad({ filter: /\.js$/ }, args => {
      // Read file and replace env vars
      const fs = require('fs');
      const path = require('path');
      
      let contents = fs.readFileSync(args.path, 'utf8');
      
      // Replace process.env patterns
      contents = contents.replace(
        /process\.env\.(\w+)/g,
        (match, varName) => {
          return JSON.stringify(process.env[varName] || '');
        }
      );
      
      return { contents, loader: 'js' };
    });
  }
};

Plugin with Virtual Modules

Virtual modules don't exist on disk but are created by plugins at build time. This pattern is extremely powerful for injecting configuration, version information, or generated content.

// Plugin that provides a virtual config module
const configPlugin = {
  name: 'config',
  setup(build) {
    build.onResolve({ filter: /^@app\/config$/ }, args => {
      return {
        path: '@app/config',
        namespace: 'app-config'
      };
    });

    build.onLoad({ filter: /.*/, namespace: 'app-config' }, args => {
      const config = {
        version: '2.1.0',
        apiUrl: process.env.API_URL || 'https://api.example.com',
        features: ['dark-mode', 'analytics']
      };
      
      return {
        contents: `export default ${JSON.stringify(config)};`,
        loader: 'js',
        resolveDir: process.cwd()
      };
    });
  }
};

// Usage in application code
// import config from '@app/config';
// console.log(config.version);

Build Context and Watch Mode

ESBuild 0.17+ introduced the context API, which replaces the older serve and watch modes with a unified, more powerful interface. A build context allows you to perform incremental rebuilds, watch the file system, and serve output over HTTP — all from a single managed instance.

// Creating a build context
const ctx = await esbuild.context({
  entryPoints: ['src/app.ts'],
  bundle: true,
  outfile: 'dist/app.js',
  sourcemap: true,
  plugins: [myPlugin],
  logLevel: 'info'
});

// Perform initial build
await ctx.rebuild();

// Start watch mode
await ctx.watch();

// Start dev server
const { host, port } = await ctx.serve({
  servedir: 'dist',
  port: 3000
});

console.log(`Dev server running at http://${host}:${port}`);

// Later, dispose the context to clean up
// await ctx.dispose();

Incremental Rebuilds

The context API maintains an in-memory cache of the dependency graph. When you call rebuild(), ESBuild only reprocesses files that have changed since the last build. This makes subsequent builds nearly instantaneous.

// Custom file watcher with incremental rebuilds
import { watch } from 'fs/promises';

const ctx = await esbuild.context({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outfile: 'dist/bundle.js',
  logLevel: 'silent' // We'll handle logging ourselves
});

// Manual rebuild loop
const watcher = watch('src/', { recursive: true });
for await (const event of watcher) {
  console.log(`File changed: ${event.filename}`);
  const start = Date.now();
  await ctx.rebuild();
  console.log(`Rebuilt in ${Date.now() - start}ms`);
}

await ctx.dispose();

Advanced Plugin Patterns

Namespace-Based Isolation

Plugins can use namespaces to isolate virtual modules and prevent conflicts. Each namespace is a separate scope — modules in one namespace won't collide with those in another.

// Multiple namespaces example
const multiNamespacePlugin = {
  name: 'multi-ns',
  setup(build) {
    // Styles namespace
    build.onResolve({ filter: /^@styles\// }, args => ({
      path: args.path,
      namespace: 'styles-ns'
    }));
    
    build.onLoad({ filter: /.*/, namespace: 'styles-ns' }, async args => ({
      contents: await fetchRemoteStyle(args.path),
      loader: 'css'
    }));
    
    // Data namespace
    build.onResolve({ filter: /^@data\// }, args => ({
      path: args.path,
      namespace: 'data-ns'
    }));
    
    build.onLoad({ filter: /.*/, namespace: 'data-ns' }, args => ({
      contents: JSON.stringify(loadData(args.path)),
      loader: 'json'
    }));
  }
};

Plugin Chaining and Ordering

Plugins are executed in the order they appear in the array. Each plugin's onResolve hooks run in order for every import, and the first plugin that returns a non-undefined result wins. This allows you to create processing pipelines.

// Plugin chain: resolve → transform → optimize
const resolveLayer = {
  name: 'resolve',
  setup(build) {
    build.onResolve({ filter: /^@custom\// }, args => ({
      path: resolveCustom(args.path)
    }));
  }
};

const transformLayer = {
  name: 'transform',
  setup(build) {
    build.onLoad({ filter: /\.ts$/ }, async args => ({
      contents: await addRuntimeTypeChecks(args.path),
      loader: 'ts'
    }));
  }
};

const optimizeLayer = {
  name: 'optimize',
  setup(build) {
    build.onEnd(result => {
      // Post-build optimization
      optimizeBundle(result.outputFiles);
    });
  }
};

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  plugins: [resolveLayer, transformLayer, optimizeLayer],
  outfile: 'dist/bundle.js'
});

Accessing Build Options in Plugins

The build object passed to plugin setup contains the initial configuration. You can read build.initialOptions to access entry points, output paths, and other settings — enabling plugins that adapt to the build configuration.

const adaptivePlugin = {
  name: 'adaptive',
  setup(build) {
    const options = build.initialOptions;
    const isProduction = options.minify === true;
    
    build.onLoad({ filter: /\.js$/ }, args => {
      const fs = require('fs');
      let contents = fs.readFileSync(args.path, 'utf8');
      
      if (isProduction) {
        // Strip development-only code
        contents = contents.replace(/if\s*\(\s*process\.env\.NODE_ENV\s*!==\s*['"]production['"]\s*\)\s*\{[\s\S]*?\}/g, '');
      } else {
        // Inject development helpers
        contents = `import { devTools } from '@app/devtools';\n` + contents;
      }
      
      return { contents, loader: 'js' };
    });
  }
};

Integration with Other Tools

ESBuild in Vite

Vite uses ESBuild as its core transpiler for both development and production. In dev mode, Vite uses ESBuild to transform TypeScript and JSX on-the-fly. For production, Vite pre-bundles dependencies with ESBuild and uses Rollup for the final application bundle. This hybrid approach gives Vite its characteristic speed while maintaining Rollup's plugin ecosystem for complex transformations.

ESBuild with Node.js Libraries

For building npm packages, ESBuild can produce both CJS and ESM outputs simultaneously. This dual-output pattern is increasingly common for library authors who want to support both legacy Node.js users and modern ESM consumers.

// Dual-format library build script
import { build } from 'esbuild';

async function buildLibrary() {
  // CommonJS build
  await build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    platform: 'node',
    target: ['node14'],
    format: 'cjs',
    outfile: 'dist/index.cjs',
    external: ['react'],
    sourcemap: true
  });

  // ESM build
  await build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    platform: 'node',
    target: ['node14'],
    format: 'esm',
    outfile: 'dist/index.mjs',
    external: ['react'],
    sourcemap: true
  });

  console.log('Library built in both formats!');
}

buildLibrary().catch(console.error);

ESBuild as a Minifier Replacement

You can use ESBuild purely as a minifier, bypassing Terser entirely. This is significantly faster and produces comparable output quality.

// Using ESBuild as a standalone minifier
import { transform } from 'esbuild';

const minified = await transform(
  originalCode,
  {
    minify: true,
    target: 'es2020',
    sourcemap: true
  }
);

console.log(minified.code);
// Write minified.code to file along with minified.map

Performance Optimization and Best Practices

1. Leverage the Binary Cache

ESBuild caches its own binary in node_modules/.cache/esbuild. When running in CI, ensure this directory is preserved between runs to avoid re-downloading the binary. You can also set the ESBUILD_BINARY_PATH environment variable to point to a pre-installed binary.

# CI optimization: cache the ESBuild binary
export ESBUILD_BINARY_PATH="/ci-cache/esbuild-bin"

2. Use Define for Dead Code Elimination

The define option replaces identifiers at compile time, enabling ESBuild's minifier to eliminate unreachable branches. This is more efficient than runtime environment checks.

// Instead of runtime checks:
if (process.env.NODE_ENV === 'production') {
  // production code
} else {
  // development code
}

// Use define to eliminate dev code entirely:
await esbuild.build({
  entryPoints: ['src/app.js'],
  define: {
    'process.env.NODE_ENV': '"production"'
  },
  minify: true
});
// The entire else branch gets removed

3. Minimize Plugin Overhead

Each plugin adds overhead because hooks are invoked for every module. Keep onResolve and onLoad filters as specific as possible to avoid unnecessary interceptions. Use narrow regex patterns rather than catch-all /.*/ filters when you can.

// Good: specific filter
build.onResolve({ filter: /^@my-lib\// }, callback);

// Avoid: broad filter when not needed
build.onResolve({ filter: /.*/ }, callback);

4. Parallelize Independent Builds

If you have multiple independent entry point sets (e.g., client and server bundles), run them as separate build() calls in parallel using Promise.all(). ESBuild already parallelizes internally, but independent build tasks can also run concurrently.

// Parallel builds for client and server
await Promise.all([
  esbuild.build({
    entryPoints: ['src/client.ts'],
    bundle: true,
    platform: 'browser',
    outfile: 'dist/client.js'
  }),
  esbuild.build({
    entryPoints: ['src/server.ts'],
    bundle: true,
    platform: 'node',
    outfile: 'dist/server.js'
  })
]);

5. Use Metafile for Analysis

The metafile option produces a detailed dependency graph. Combined with --analyze on the CLI, you get a text-based visualization of which modules are largest and which imports create the most weight. Use this to identify optimization opportunities.

// Generate and analyze metafile
const result = await esbuild.build({
  entryPoints: ['src/app.js'],
  bundle: true,
  metafile: true,
  minify: true,
  outfile: 'dist/app.js'
});

// Write metafile for external analysis tools
import fs from 'fs';
fs.writeFileSync('meta.json', JSON.stringify(result.metafile, null,

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles