esbuild: Speed Written in Go, Its API, Plugins and Limits
esbuild is fast because it compiles to native code with Go, spreads work across CPU cores and walks the AST only three times; it bundled all of lodash-es about 22 times faster than webpack, but 9% larger. On the Bookstore widget we use its CLI, JS API, metafile and onResolve/onEnd plugins, and look at its limits in type checking, HMR, HTML and code splitting, and at its place inside the Angular CLI.
Version note: The examples in this post were run with esbuild 0.28, TypeScript 7.0 and Node.js 24; the comparisons use Vite 8.3, Rolldown 1.2 and webpack 5.112, and browser checks were done with Playwright 1.65's Chromium. Timings come from the author's own machine; look at the ratios, not the absolute values.
Guess First
The book search widget of Bookstore, the online bookstore used as the shared example across this site's series, only uses debounce from lodash-es; but comparing bundler speed needs a heavier job. This file pulls all of lodash-es, 640 modules, into the bundle:
import * as _ from "lodash-es";
console.log(Object.keys(_).length);
We wrote a script that bundles and minifies this file five times each with esbuild, Rolldown and webpack in the same Node.js process. On this machine webpack's median came out at about 450 ms. How long do you think esbuild took: half of webpack, a tenth, or even less? Second question: are the three outputs the same size, and if not, which is the smallest?
This is the third post in the Build Tools series. After webpack we look at a tool written with the opposite philosophy: esbuild. On the same widget we see why it is so fast, its CLI and JS API, its plugins and, above all, where it falls short.
Why Is esbuild So Fast?
The result of the measurement:
npm run bench
esbuild median 20 ms, output 95.6 KiB
rolldown median 17 ms, output 88.3 KiB
webpack median 447 ms, output 87.8 KiB
For this job esbuild is about 22 times faster than webpack. But the table's second column matters too: esbuild's output is about 9% larger than the other two. esbuild deliberately gives up some optimizations for speed; webpack's minifier, Terser, and Rolldown's, Oxc, compress the code with more passes and more aggressively. Rolldown matching esbuild's speed on this job is also an important fact of 2026: the new generation of Rust-based tools has largely closed esbuild's speed advantage.
esbuild's own documentation explains the source of its speed in a few points. Being written in Go, it compiles to native code and does not wait for a JavaScript engine to warm up on every run. Parsing and code generation are spread across CPU cores in parallel; Go's ability to share memory between threads removes the data-copying cost that JavaScript workers have. The code walks the AST only three times; jobs such as transforms, minification and source maps are combined into the same passes instead of separate ones, so the data stays in the CPU cache. Finally, the tool was written from scratch: it depends on no third-party library, its TypeScript parser included, so its data structures are designed for performance end to end.
On a small project the picture looks different. These are the medians we measured from the command line while building the widget itself: esbuild CLI 20 ms, the same build run through a Node.js script 61 ms, vite build 99 ms, webpack 294 ms. The 40 ms gap between the script and the CLI is purely Node.js startup time. In a small project the tools' actual work takes milliseconds and startup cost dominates; the gap opens up as the project grows.
Bundling with One Command: the esbuild CLI
The simplest way to use esbuild is a single command:
npx esbuild src/main.ts --bundle --minify --outdir=dist-cli
dist-cli/main.js 4.0kb
dist-cli/main.css 253b
⚡ Done in 31ms
No config file, no loaders. The CSS imported by main.ts came out as a separate file next to the JS. The --bundle flag is critical: esbuild does not bundle by default, it only transforms the given file; without the flag the imports stay as they are.
Watch the defaults. The output format is iife (a classic script wrapped in a function that runs as soon as it is defined), and the target is esnext, meaning no syntax is lowered. The CSS shows it:
#book-finder{max-width:32rem;font-family:system-ui,sans-serif;.finder-input{width:100%;padding:.5rem .75rem;border:1px solid #94a3b8;border-radius:.375rem}.finder-results{list-style:none;padding:0;li{padding:.375rem 0;border-bottom:1px solid #e2e8f0}}}
The nesting stayed as written. Vite 8 had flattened the same CSS with its default Baseline target. esbuild has no notion of Baseline or Browserslist; you have to give the target explicitly as browser versions: --target=chrome111,edge111,firefox114,safari16.4. These four values are the same versions Vite 8 uses for baseline-widely-available; we use them in the build script below too.
A Build Script with the JS API
In a real project, as CLI flags pile up you move to a build script. esbuild has no such thing as a config file; the config is an object passed to its JavaScript API:
import * as esbuild from "esbuild";
import { writeFile } from "node:fs/promises";
import { htmlPlugin } from "./esbuild/html-plugin.mjs";
import { noCommonJsLodash } from "./esbuild/no-commonjs-lodash.mjs";
const serve = process.argv.includes("--serve");
const options = {
entryPoints: ["src/main.ts"],
bundle: true,
format: "esm",
splitting: true,
target: ["chrome111", "edge111", "firefox114", "safari16.4"],
minify: !serve,
sourcemap: serve,
outdir: "dist-esbuild",
entryNames: "[name]-[hash]",
metafile: true,
logLevel: "info",
plugins: [noCommonJsLodash(), htmlPlugin({ template: "index.html", entry: "src/main.ts", liveReload: serve })],
};
if (serve) {
const ctx = await esbuild.context(options);
await ctx.watch();
await ctx.serve({ servedir: "dist-esbuild", port: 8000 });
} else {
const result = await esbuild.build(options).catch(() => process.exit(1));
await writeFile("dist-esbuild/meta.json", JSON.stringify(result.metafile));
console.log(await esbuild.analyzeMetafile(result.metafile));
}
metafile: true returns a JSON document at the end of the build: which input file contributed how many bytes to which output, and who imported whom. analyzeMetafile turns it into a readable table:
npm run build:esbuild
dist-esbuild/main-XBSKC5GL.js 4.0kb
dist-esbuild/main-ZY63JJGU.css 306b
⚡ Done in 37ms
dist-esbuild/main-XBSKC5GL.js 4.0kb 100.0%
├ node_modules/lodash-es/debounce.js 940b 23.1%
├ src/finder.ts 487b 12.0%
├ src/books.ts 379b 9.3%
├ node_modules/lodash-es/toNumber.js 345b 8.5%
...
├ node_modules/lodash-es/now.js 44b 1.1%
└ node_modules/lodash-es/_Symbol.js 19b 0.5%
dist-esbuild/main-ZY63JJGU.css 306b 100.0%
└ src/styles.css 305b 99.7%
You do not need to install a separate analyzer to see what the bundle is made of; the same meta.json file can also be uploaded to the visual analyzer on esbuild's website. We compare bundle analysis across tools in the series' code splitting and tree shaking post. Because this build has a target, the CSS was flattened and grew from 253 bytes to 306; thanks to [hash], the file names were derived from content too.
The Plugin API: onResolve, onLoad and onEnd
An esbuild plugin is an object with a setup(build) function. Inside it you register three kinds of callbacks: onResolve decides which file an import path resolves to, onLoad provides a file's contents and the loader to process it with, and onStart and onEnd run at the beginning and end of the build. Every callback has a filter, and it is a Go regular expression; esbuild rules out a path on the Go side without ever crossing into JavaScript. Keeping the filter broad (/.*/) means a JavaScript round trip on every import and eats much of the speed.
Our first plugin is a guard. The CommonJS version of lodash (lodash) cannot be tree-shaken; if someone imports it by mistake, the build should stop:
// Stops the build if someone imports the CommonJS "lodash" instead of "lodash-es"
export function noCommonJsLodash() {
return {
name: "no-commonjs-lodash",
setup(build) {
build.onResolve({ filter: /^lodash(\/.*)?$/ }, args => ({
errors: [{ text: `"${args.path}" is CommonJS and cannot be tree-shaken; import from "lodash-es" instead` }],
}));
},
};
}
After changing the import in finder.ts to from "lodash":
✘ [ERROR] "lodash" is CommonJS and cannot be tree-shaken; import from "lodash-es" instead [plugin no-commonjs-lodash]
src/finder.ts:1:25:
1 │ import { debounce } from "lodash";
╵ ~~~~~~~~
1 error
The error is reported with line and column information and the plugin's name. Since the check runs before resolution, the lodash package does not even need to be installed.
The second plugin takes on a job esbuild never does: HTML. esbuild does not process HTML files; you have to write the hashed file names into the page yourself. The onEnd callback reads the metafile when the build finishes and replaces the script tag in the template with the real file names:
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
// esbuild does not process HTML: after the build, write the hashed file names into the template
export function htmlPlugin({ template, entry, liveReload = false }) {
return {
name: "html",
setup(build) {
build.onEnd(async result => {
if (result.errors.length > 0) {
return;
}
const outdir = build.initialOptions.outdir;
const [file, output] = Object.entries(result.metafile.outputs)
.find(([, output]) => output.entryPoint === entry);
const href = name => "./" + path.relative(outdir, name).replaceAll("\\", "/");
const css = output.cssBundle ? ` <link rel="stylesheet" href="${href(output.cssBundle)}">\n` : "";
const scripts = [
`<script type="module" src="${href(file)}"></script>`,
liveReload ? `<script type="module">new EventSource("/esbuild").addEventListener("change", () => location.reload());</script>` : "",
].filter(Boolean).join("\n ");
const html = (await readFile(template, "utf8"))
.replace("</head>", `${css}</head>`)
.replace(/<script type="module" src="[^"]+"><\/script>/, scripts);
await writeFile(path.join(outdir, "index.html"), html);
});
},
};
}
The metafile's cssBundle field points to the file generated from the CSS a JS entry imports. We opened the resulting page in Chromium: 4.6 KB arrived over 3 requests (HTML, CSS, JS), and a search for "austen" listed Emma and Pride and Prejudice. We did not use onLoad in these two plugins, but the idea is the same: when a file matching the filter is loaded you return its contents, for example reading a YAML file and handing it over as JSON with loader: "json", or generating a virtual module.
No Type Checking: tsc Is a Separate Step
esbuild only transforms TypeScript; it does not check types, and its documentation says so plainly and recommends running tsc --noEmit alongside it. We saw the same situation with Vite in the first post of the series: when we wrote a price as a string, the build finished successfully and only tsc caught the error. The result with esbuild is the same. That is why typecheck is a separate script in the widget's package.json; in CI it runs before or in parallel with the build. With TypeScript 7's Go-based compiler this step takes 51 ms on the widget; type checking now takes about as long as esbuild itself.
Because esbuild transforms each file independently of the others, it cannot support TypeScript features that need type information. The main ones listed in the documentation:
| Topic | esbuild's behavior |
|---|---|
| Type checking | None; run tsc --noEmit separately |
isolatedModules | Should be on; write export type { T } instead of re-exporting a type with export { T } |
emitDecoratorMetadata | Not supported; it needs the type system |
| Decorators | TypeScript's legacy behavior if experimentalDecorators is on, standard JavaScript decorators if it is off |
tsconfig.json | Only specific fields are read: target, paths, the JSX settings, verbatimModuleSyntax, experimentalDecorators, useDefineForClassFields, extends and a few more |
The last row is an often missed trap: fields in tsconfig such as lib, noUnusedLocals or moduleResolution mean nothing to esbuild. The emitDecoratorMetadata row matters too: dependency injection libraries that rely on reflect-metadata do not work with esbuild. Vite 8 closed that gap with Oxc and supports emitDecoratorMetadata out of the box. On the Angular side, decorators are handled by Angular's own compiler, not by esbuild.
Limits: No HMR, Splitting Only for ESM
esbuild has watch and serve modes, but no HMR. Its FAQ lists HMR, Module Federation and type checking among the features that will not be added to esbuild. What it can do is live reload: serve mode opens an event stream at /esbuild and sends a change event on every rebuild. In serve mode our HTML plugin adds a one-line script to the page that listens for this event and reloads the page:
npm run dev:esbuild
[watch] build finished, watching for changes...
> Local: http://127.0.0.1:8000/
> Network: http://192.168.1.20:8000/
We opened the page in Chromium and changed the placeholder text in finder.ts. esbuild noticed the change ([watch] build started (change: "src/finder.ts")), the page loaded a second time and the new placeholder appeared. But this is a full reload: the text you typed into the search box and all the state on the page are lost. Vite's HMR replaces only the changed module; we see that in the next post of the series. esbuild's documentation also shows more advanced client code that swaps the stylesheet without reloading the page when only CSS changes; there is no such path for JavaScript.
The second limit is code splitting. The documentation still marks it as a work in progress, and it only works with ESM output:
npx esbuild src/main.ts --bundle --splitting --format=iife --outdir=dist-iife
✘ [ERROR] Splitting currently only works with the "esm" format
1 error
The third is versioning. esbuild is still at 0.x and, according to its FAQ, breaking changes arrive in minor versions and backward-compatible fixes in patch versions. 0.28.0 itself was such a release: it added integrity checks for the platform binaries downloaded through npm. The ^0.28.2 in package.json does not cover 0.29 in npm, so it is safe; but always treat moving from 0.28 to 0.29 like a major version upgrade and read the CHANGELOG.
Where Is esbuild Used? A Bridge to the Angular CLI
Considering its speed and its limits together, it becomes clear where esbuild is at its best: inside another tool, as a fast engine. Up to Vite 7, Vite's dependency pre-bundling and TypeScript transforms on the dev server side were done with esbuild; Vite 8 moved those jobs to Rolldown and Oxc and demoted esbuild to an optional dependency. In the Angular CLI, on the other hand, esbuild sits at the center. Angular's build system documentation describes esbuild as the new system's bundler and says Vite is used only as the development server; the dependency list of @angular/build 22.2.7 contains esbuild 0.28.2, Vite 8.3.5 and Rolldown 1.2.16 together. Angular's compiler processes templates and decorators, and esbuild bundles the resulting JavaScript. We look at this builder's internals, Rolldown's role there and the migration from the webpack-based browser builder in detail in the series' Angular CLI post.
esbuild on its own is the right choice for these jobs: bundling small scripts for a few pages of a server-rendered site like Bookstore.Web (an MPA), packaging a CLI tool or a Lambda function for Node.js, producing a library's ESM output, and transforming TypeScript quickly inside other tools such as test runners.
In Production
Pin esbuild's version and upgrade deliberately. 0.x versioning treats minor versions as breaking; commit package-lock.json and use npm ci in CI.
Write the target explicitly. The default esnext lowers no syntax; code that does not run in the oldest browser you support goes to production silently. If you are in a project that uses Browserslist, add a step to the build script that converts that list into a form esbuild understands, and generate both lists from a single source.
Keep the metafile as a CI artifact. meta.json records what the bundle is made of on every build; you can answer "why did the bundle grow between two releases" by comparing two of these files.
Keep plugin filters narrow. A plugin attached to every file with filter: /.*/ moves work that would stay on esbuild's Go side over to JavaScript and slows the build down noticeably.
Common Pitfalls
First, thinking of esbuild as a tool that does everything. It does not process HTML, does not type-check, has no HMR, does not read Browserslist and does not run PostCSS plugins on CSS. Either you add those yourself with plugins, or you use esbuild inside a tool that does them (Vite 7, the Angular CLI).
Second, embedding dependencies in a library build. We packaged the widget's finder.ts as a library in two ways:
npx esbuild src/finder.ts --bundle --format=esm --minify --outfile=dist-lib/finder.js
# dist-lib/finder.js 3.6kb
npx esbuild src/finder.ts --bundle --format=esm --minify --packages=external --outfile=dist-lib/finder.js
# dist-lib/finder.js 807b
In the first, lodash's debounce code went inside the library. If an application using this library also uses lodash-es, the same code ends up twice in its bundle; if the dependency is a framework (React, Angular), two copies cause errors at run time. --packages=external leaves every package import as it is: the output starts with import{debounce as u}from"lodash-es". In libraries, packages in peerDependencies and dependencies should be external; if you only want to pick specific ones, use --external:lodash-es.
Third, forgetting --bundle. Without the flag esbuild only transforms the given file and leaves the imports as they are; the output fails in the browser with Failed to resolve module specifier.
Fourth, treating esbuild's CSS like PostCSS. It lowers nesting and some newer syntax according to the target, bundles @import and url(), and provides CSS Modules for .module.css; but it does not run a pipeline built on PostCSS plugins, such as Tailwind, by itself.
A Small Experiment
★☆☆ Run npx esbuild src/main.ts --bundle --outdir=dist-cli once with --minify, once without it and once with --target=es2015. Compare the sizes of the three outputs and the differences in the CSS.
★★☆ Generalize the noCommonJsLodash plugin: have it take banned package names and their suggested alternatives from an object (for example Intl.DateTimeFormat for moment). Try the plugin with a banned package and check the error message.
★★★ Write a small Node.js script that reads dist-esbuild/meta.json: it should list the five inputs that contribute the most bytes to the output and exit with a non-zero exit code if the total size exceeds a threshold. Add this script to package.json so it runs after the build in CI.
In the next post we move to the tool that combines esbuild-like speed with a dev server experience: Vite. By connecting the widget to Bookstore.Web, we look at its native ESM dev server, HMR, dependency pre-bundling and the move of production builds to Rolldown in Vite 8.