esbuild API and CLI for Rapid Builds
Modern frontend toolchains increasingly rely on deterministic, Go-native execution to bypass the latency ceilings of JavaScript-based bundlers. This guide isolates esbuild’s raw API surface and CLI mechanics, providing framework-agnostic patterns for build tooling developers, framework maintainers, and performance-focused frontend engineers. For where this fits in the broader native-toolchain picture, see esbuild & Turbopack Workflows before wiring esbuild into a pipeline. All workflows target esbuild v0.25.x and assume a Node.js v20+ runtime.
The problem this guide addresses exists because JavaScript-based bundlers spend most of their wall-clock time in the JavaScript VM: parsing every module into an AST, walking that AST in interpreted code, and garbage-collecting the intermediate objects. esbuild sidesteps that ceiling by doing the parse, the scope analysis, the tree-shaking, and the code generation in compiled Go with a shared-memory representation and aggressive parallelism across CPU cores. The practical consequence is that the bottleneck moves from “how fast can we parse” to “how fast can we shuttle bytes across the Node-to-Go boundary” — which is why the choice of entry point, and how often you cross that boundary, dominates real-world performance far more than any single flag.
The mental model worth fixing first: esbuild exposes three distinct entry points — build(), context(), and transform() — and choosing the wrong one is the root cause of most performance and correctness complaints. build() is a one-shot graph build. context() is a persistent build you reuse for watch mode, serving, and incremental rebuilds. transform() is a stateless single-string converter that never touches the file system. The CLI is a thin wrapper over build() (plus --watch/--serve which create a context internally).
What breaks without a clear grasp of these boundaries is subtle and expensive. Teams reach for transform() inside a loop and are surprised that imports vanish, because transform() never resolves a module specifier. Or they call build() on every file-save in a homegrown watcher and watch rebuild latency climb from tens of milliseconds to whole seconds, because each call re-spawns the Go service, re-reads the entire graph from disk, and discards the parse cache the moment it returns. Or they wire a serve context into a dev server and never dispose it, and the process accumulates file-watcher handles until the OS refuses to open more. Every one of those failures traces back to picking the wrong lifecycle for the job, so the rest of this guide is organized around the lifecycle each entry point implies rather than around a flag reference.
Prerequisites
- esbuild 0.25.x installed locally (
npm install --save-dev esbuild), not a global binary — version drift between a global CLI and the API package produces confusing flag-parity bugs. - Node.js 20+. The API ships both ESM and CJS entry points; examples below use
import * as esbuild from 'esbuild'with"type": "module"inpackage.json. - A reproducible entry point such as
src/index.ts. esbuild does not readtsconfig.jsonfor thetransform()API and only honors a subset (paths,target,jsx*) forbuild(), so do not assume yourtsconfigflags carry over.
The tsconfig caveat deserves more than a footnote because it silently breaks builds that “worked in tsc”. esbuild is a transpiler, not a type checker: it erases types and never evaluates strict, noUnusedLocals, experimentalDecorators semantics beyond syntax, or path constraints outside the small honored set. For build(), the fields that actually change output are compilerOptions.paths and baseUrl (import resolution), target (down-levelling, though the esbuild target option overrides it), jsxFactory/jsxFragmentFactory/jsx/jsxImportSource (JSX codegen), useDefineForClassFields, importsNotUsedAsValues/verbatimModuleSyntax (whether type-only imports are elided), and alwaysStrict. Everything else in your tsconfig — every safety check you rely on — is invisible to esbuild. The correct division of labour is to let esbuild emit JavaScript at full speed and run tsc --noEmit as a separate, parallel gate; if you conflate the two you will ship code esbuild happily transpiled but tsc would have rejected.
transform() reads none of it, build() only a subset.Execution Models and Process Lifecycle
The architectural boundary between esbuild’s CLI and JavaScript API dictates memory footprint, process longevity, and cache persistence. The CLI operates as a stateless, single-invocation process ideal for CI/CD pipelines, while the JS API exposes a persistent execution context optimized for long-running development servers and incremental rebuilds.
Underneath both surfaces sits the same mechanism: the first time your Node process calls into esbuild, the package spawns a long-lived Go child process (the “service”) and talks to it over stdin/stdout using a length-prefixed binary protocol. Every build(), transform(), or rebuild() call serializes its arguments, ships them across that pipe, and waits for the Go side to serialize results back. This is why the very first call in a process carries a fixed spawn cost of a few milliseconds that later calls do not, and why keeping one Node process alive and reusing it is faster than shelling out to the esbuild binary repeatedly — the CLI pays the spawn-and-teardown cost on every invocation, whereas a persistent Node host pays it once. It also explains a class of confusing errors: if you fork your Node process after esbuild has started its service, the child inherits a broken handle to a service it does not own, so start esbuild after forking, not before.
The stateless-versus-persistent split maps cleanly onto where you run each model. In CI, statelessness is a feature: a fresh process guarantees no stale cache poisons the artifact, the metafile is deterministic, and a non-zero exit code is the entire contract the pipeline needs. In development, statelessness is a liability: every keystroke-triggered rebuild would throw away the parse cache that makes esbuild feel instant. The context() API exists precisely to let a development process straddle the line — one persistent service, one retained module graph, many cheap rebuilds — while still giving you an explicit dispose() to collapse back to a clean process on shutdown.
context() retains state — and only it must be disposed, or the Go subprocess leaks.Persistent Context Initialization
The esbuild.context() API (introduced in v0.18.0) replaces the legacy watch: true flag with explicit lifecycle management. This enables deterministic resource allocation and graceful teardown. The full watch and serve workflow is covered in Using esbuild context watch mode for incremental rebuilds; the skeleton below shows the lifecycle contract.
// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
async function initDevPipeline() {
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
format: 'esm',
sourcemap: 'inline',
logLevel: 'info',
});
// Start file watcher with incremental rebuilds
await ctx.watch();
// Graceful shutdown hook — without dispose, FSWatcher handles leak
const shutdown = async () => {
console.log('Disposing build context...');
await ctx.dispose();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
initDevPipeline().catch((err) => {
console.error(err);
process.exit(1);
});
Performance impact: Persistent contexts retain the parsed module graph in memory, so subsequent rebuilds re-resolve only changed files. Skipping ctx.dispose() is the single most common leak — the underlying Go process and its FSWatcher handles stay alive and the Node heap grows linearly across reloads.
How incremental rebuild works under the hood
The word “incremental” oversells what esbuild caches, and understanding the actual mechanism prevents you from expecting savings that never materialize. A context does not diff ASTs or patch modules in place. What it retains is the resolved module graph and the parsed representation of every input file keyed by path and content. When ctx.rebuild() fires — either explicitly or because watch() observed a filesystem event — esbuild re-stats the entry points, walks the graph, and for each file compares the on-disk content against what it parsed last time. Files whose bytes are unchanged reuse their cached parse; files that changed are re-lexed and re-parsed, and any module that imported a changed file may be re-linked. The bundling and code-generation phases then run again over the assembled graph. In other words, the cache is at the granularity of “parse this file,” not “regenerate this chunk,” so a one-character edit to a widely-imported module still triggers a full re-link and re-emit even though only one file was re-parsed. This is why rebuild latency scales with graph size and output size rather than with edit size, and why splitting a monolithic entry into several smaller ones can make edits in one area feel faster.
The invalidation is content-addressed, not timestamp-addressed, which matters for correctness. A build tool that touched a file’s mtime without changing its bytes will not trigger a re-parse, and conversely a file whose content changed but whose mtime the editor preserved will still be re-parsed because esbuild reads the bytes. If you generate code into the graph from a plugin, the cache key is whatever you return from onLoad, so a plugin that returns non-deterministic contents (a timestamp, a random id) defeats the cache and forces a re-parse on every rebuild. Keep plugin output pure with respect to its inputs or you silently turn every incremental rebuild back into a cold build.
Debugging Lifecycle Leaks
- Heap snapshot validation: Run
node --inspectand capture heap snapshots before and afterctx.dispose(). A persistent delta points to a context that was never disposed. The symptom is a Node process whose resident memory climbs monotonically across a long-running dev session; the root cause is almost always a context created inside a request handler or a hot-reload callback and never torn down, so each reload leaks a full retained graph. The fix is to create the context once at process start and reuse itsrebuild(), or, if you genuinely need per-request contexts, toawait ctx.dispose()in afinallyblock. Confirm the fix by taking a snapshot, forcing several reloads, taking another, and checking that the number ofcontextobjects is stable rather than growing. - Process exit verification: Pass
--log-level=debugto trace the worker process lifecycle. A clean exit confirms teardown; a hung process indicates a retained context or an unhandled rejection in a plugin hook. The tell-tale symptom is anodeprocess that ignores Ctrl-C once and exits only on a second, harder signal — Node cannot exit while the Go service’s stdio handles are still referenced. Trace it by logging inside yourSIGINThandler: if the log prints but the process never exits, a context is still alive somewhere. Confirm the fix by verifying the process exits on the firstSIGINTwith exit code 0. - Orphaned handle tracing:
process.getActiveResourcesInfo()(Node 18.7+) reveals lingeringFSWatcherorTCPSocketWrapinstances from a serve context that outlived its dispose call. Call it right before you expect the process to exit; anyFSWatcherentries mean awatch()context is still open, andTCPSocketWrapentries mean aserve()context is still bound to its port. The fix is to dispose every context you created — track them in an array and dispose all of them on shutdown. Confirm by re-running the same probe after disposal and seeing the esbuild-owned handles gone.
Programmatic Transform and Build Pipelines
The esbuild.transform() and esbuild.build() APIs decouple transpilation from bundling, enabling framework-agnostic preprocessing layers. This separation is critical for tooling maintainers who require fast single-file syntax transformation without invoking full graph resolution.
transform() is a codegen pass; needing imports or multiple files is the signal to switch to build().For build tooling maintainers, the transform() API serves as a lightweight preprocessing layer. A common implementation pattern involves Using esbuild transform API for TypeScript stripping to accelerate hot-reload cycles while deferring type validation to dedicated CI steps.
// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
async function preprocessModule(rawCode: string, filePath: string) {
// Strip TS types, compile JSX, target modern syntax — no fs access
const result = await esbuild.transform(rawCode, {
loader: filePath.endsWith('.tsx') ? 'tsx' : 'ts',
target: 'esnext',
jsx: 'automatic',
sourcemap: 'inline',
sourcefile: filePath,
});
for (const w of result.warnings) console.warn(w.text);
return result.code;
}
transform() never reads tsconfig.json, never resolves imports, and emits no .d.ts files — it is a pure lexer-plus-codegen pass. Reach for build({ bundle: false }) the moment you need import resolution or multi-file output.
The reason transform() is so much faster per call than build() is that it skips every phase that touches the filesystem or the module graph. A build() call must resolve each import specifier against the resolution algorithm (relative paths, node_modules walking, package.json exports maps, tsconfig paths), read every reachable file, parse them all, link the graph, tree-shake, and lay out output chunks. transform() does none of that: it receives one string, runs the same lexer and code generator esbuild uses internally, and returns one string plus an optional source map. That makes it the right primitive for a plugin in another tool — a test runner that needs to strip types from a single file on import, a server-side loader hook, an on-the-fly REPL — where you already know the file’s contents and have your own resolution story.
The corollary is that when you pass transform() a .tsx file that uses import type, or one that relies on a paths alias, you get output that references specifiers esbuild made no attempt to rewrite. That is not a bug; it is the contract. If your input depends on resolution — path aliases, bare imports you expect to be inlined, JSX runtime auto-import that must trace back to an installed package — you have outgrown transform() and must move to build() so esbuild owns the graph. A useful heuristic: if you can answer “what does this import resolve to?” without reading any other file, transform() is safe; the instant the answer requires the filesystem, it is not.
Here is the same preprocessing step expressed as a build() call, for the case where a single input does pull in relative imports that must be inlined into one output string:
// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
async function bundleEntryToString(entry: string) {
// bundle: true resolves and inlines the whole reachable graph
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false, // keep output in memory instead of writing to disk
format: 'esm',
target: 'es2022',
sourcemap: 'inline',
metafile: true,
});
// write:false returns outputFiles; [0] is the single ESM bundle
return {
code: result.outputFiles[0].text,
graph: result.metafile, // inspect inputs/outputs without touching disk
};
}
The write: false option is the bridge between the two worlds: you get build()'s full resolution and bundling but receive the bytes in memory as outputFiles instead of on disk, which is exactly what an in-process tool wants. It also pairs naturally with metafile: true so you can inspect the resolved graph programmatically in the same call.
Zero-Config CLI Optimization Strategies
esbuild’s CLI exposes production-grade optimizations without a config file. Chaining native flags enforces tree-shaking, enables ESM code splitting, and generates an audit-ready metafile. Bundle-shrinking flags are detailed in Reducing esbuild bundle size with minify and tree-shaking.
--splitting silently requires --format=esm and --outdir — the most common "why didn't it split" gap.# esbuild 0.25.x
esbuild src/index.ts \
--bundle \
--format=esm \
--splitting \
--outdir=dist \
--metafile=meta.json \
--sourcemap=linked \
--minify
Flag Parity and Optimization Mechanics
--splitting: Generates shared chunks from common ESM imports. Requires--format=esmand--outdir. The mechanism is that when two entry points both import the same module, esbuild hoists that module into a shared chunk both entries import, rather than duplicating it into each bundle. Without--format=esmthere is noimportstatement to emit for the shared chunk, so the flag is silently a no-op; without--outdirthere is nowhere to place multiple output files. The consequence of getting this wrong is not an error but bundles that are quietly larger than they should be because shared code is duplicated — confirm splitting actually happened by checking forchunk-*.jsfiles in the output and cross-referencing the metafile’soutputsmap.--metafile=meta.json: Emits a deterministic JSON graph of inputs, outputs, and byte sizes for analysis. The parse cost is negligible relative to the build. Each entry ininputsrecords a file’s byte size and its list of imports with the resolved path and import kind, and each entry inoutputsrecords which inputs contributed how many bytes to that output. This is the ground truth for any bundle-size regression investigation: it attributes every output byte back to a source file, so a chunk that ballooned points you straight at the dependency that grew rather than leaving you to guess.--minify: Combines--minify-syntax,--minify-whitespace, and--minify-identifiers. Use the individual flags when you need to keep readable identifiers for stack traces. Whitespace minification removes formatting, syntax minification rewrites constructs into shorter equivalents (collapsingif/elseinto ternaries, folding constant expressions), and identifier minification renames local bindings to short names. The last one is the one that breaks production debugging, because it renames the symbols your stack traces reference. If you ship a source map you can keep full minification and still symbolicate; if you do not, drop--minify-identifiersso error reports stay readable, and accept the marginal size increase.
Step-by-step: a deterministic build-and-verify loop
dispose() — the step that separates a clean exit from a leaked subprocess.- Install pinned esbuild —
npm install --save-dev esbuild@0.25so the CLI and API agree on flag shape. - Author the entry build — write the
build()or CLI invocation with--bundle --format=esm --metafile=meta.json. - Run the build —
node build.mjs(or the CLI line above) and confirm exit code0. - Inspect the metafile —
npx esbuild --analyze=verbose < meta.jsonor feedmeta.jsontoesbuild.analyzeMetafile()to print a byte-attributed tree. - Switch to a context for dev — replace
build()withcontext()+ctx.watch()for incremental rebuilds. - Dispose on shutdown — wire
SIGINT/SIGTERMtoctx.dispose()so watchers and the Go subprocess exit cleanly.
Custom Resolvers and Asset Pipeline Integration
esbuild’s plugin architecture relies on a two-phase resolution model: onResolve (path mapping) and onLoad (content injection). Deterministic execution is enforced via namespace isolation and filter precedence, enabling virtual-module injection and non-standard asset routing. When integrating non-standard file types, consult Custom Loaders and Asset Handling for MIME mapping and cache-busting strategies.
namespace key is why an injected virtual module silently resolves against the filesystem instead.// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
const virtualPlugin: esbuild.Plugin = {
name: 'virtual-module-resolver',
setup(build) {
// Phase 1: intercept the virtual import and assign a namespace
build.onResolve({ filter: /^@virtual\/config$/ }, (args) => ({
path: args.path,
namespace: 'virtual',
}));
// Phase 2: inject content for that namespace
build.onLoad({ filter: /.*/, namespace: 'virtual' }, () => ({
contents: `export const CONFIG = { env: 'production', debug: false };`,
loader: 'ts',
resolveDir: process.cwd(),
}));
},
};
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
plugins: [virtualPlugin],
});
Debugging Resolution Conflicts
- Extension precedence: Override default resolution with
--resolve-extensions=.ts,.js,.mjs. Ambiguity arises when.tsxand.tsshare a base name; explicit ordering removes it. The symptom is that an import of./widgetresolves towidget.jswhen you expectedwidget.ts, because the default extension order lists.tsx,.ts,.jsx,.js,.cssand stops at the first hit, but a stale compiled.jssitting next to the source can win depending on how you reordered the list. The root cause is two files with the same base name and different extensions in one directory; the fix is either to delete the stray artifact or to make the ordering explicit so the source extension precedes the compiled one. Confirm by grepping the metafileinputsfor which path actually resolved. - Loader mapping validation: Verify
--loader:.png=dataurlor--loader:.svg=textbehavior by inspecting output. Data URLs inflate the bundle, so reserve them for tiny assets and emit larger files as separate outputs. The trap here is the base64 tax: a data URL encodes bytes at roughly a 4:3 expansion and lands inline in your JavaScript, so a 40 KB PNG becomes ~54 KB of parser-blocking string that cannot be cached separately from the bundle. The fix is to route anything but sub-kilobyte assets through thefileloader, which emits a separate output with a hashed name and leaves only a URL string in the bundle. Confirm the choice by checking the output byte size in the metafile before and after switching loaders. - Namespace isolation: An
onLoadcallback only fires for the namespace its filter declares; forgetting thenamespacekey is why injected virtual modules silently fall through to the file system. The symptom is an “file not found” error for a path that never existed on disk, or a virtual module that loads real filesystem contents instead of your injected string. The root cause is anonResolvethat assigned a namespace paired with anonLoadwhose filter omitted that same namespace, so the load falls back to the defaultfilenamespace and esbuild tries tostata path you invented. The fix is to make thenamespaceinonLoadmatch the oneonResolvereturned exactly; confirm by logging inside theonLoadcallback to prove it fires.
Performance Diagnostics and Incremental Build Tuning
esbuild excels at cold-start performance, but sustained development workflows need explicit context reuse and diagnostic instrumentation. Engineers transitioning from slower bundlers should analyze Turbopack Incremental Compilation to understand how graph invalidation differs across Go and Rust execution models.
build() in a loop re-parses the whole graph; a reused context() is dramatically faster.- Metafile-driven audits:
esbuild.analyzeMetafile(meta, { verbose: true })attributes bytes to each input, exposing duplicated package versions and oversized dependencies. The most common finding is two copies of the same library at different semver ranges pulled in by two dependencies — the verbose tree shows bothnode_modules/fooandnode_modules/bar/node_modules/foo, and the fix is a dependency dedupe or an override, not anything in the esbuild config. Run this in CI and fail the build when a known-heavy input crosses a byte threshold so regressions surface at the PR, not in production. - Context reuse over re-spawning: Calling
build()in a loop re-parses the whole graph each time; a singlecontext()withctx.rebuild()reuses the in-memory graph and is dramatically faster for repeated builds. The failure mode is a script that iterates over a list of entry points callingbuild()once per entry — each call re-spawns nothing but does re-read and re-parse every shared dependency, so a project with fifty entries pays the parse cost of the common graph fifty times. Passing all entries to a singlebuild()call, or holding one context and rebuilding, collapses that to one parse. - Plugin profiling: Wrap custom
onResolve/onLoadhooks withperformance.now(). A hook that runs on a broadfilter: /.*/fires for every module and is the usual cause of a slow incremental rebuild. Thefilteris a Go-native regular expression evaluated on the fast side of the boundary, but the callback runs in JavaScript on the slow side, so every module that matches the filter forces a round-trip into your Node code. Tighten the filter to the narrowest pattern that still matches your targets — anchor it, restrict the extension — so the overwhelming majority of modules never cross back into JavaScript at all. Confirm the win by summing yourperformance.now()deltas before and after tightening.
Here is a minimal harness that both attributes bytes and measures a plugin hook’s aggregate cost, so a slow rebuild becomes a number rather than a hunch:
// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
let hookTotalMs = 0;
const timedPlugin: esbuild.Plugin = {
name: 'timed-load',
setup(build) {
// Narrow filter: only .svg, so unrelated modules never cross into JS
build.onLoad({ filter: /\.svg$/ }, async (args) => {
const start = performance.now();
const svg = await import('node:fs/promises').then((fs) =>
fs.readFile(args.path, 'utf8'),
);
hookTotalMs += performance.now() - start;
return { contents: svg, loader: 'text' };
});
},
};
const result = await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
metafile: true,
plugins: [timedPlugin],
});
console.log(`svg onLoad aggregate: ${hookTotalMs.toFixed(1)}ms`);
console.log(await esbuild.analyzeMetafile(result.metafile, { verbose: true }));
CI integration
In CI the build() model is the right one, but the details determine whether a broken build fails loudly or ships silently. esbuild returns a resolved promise with a warnings array and rejects with an error whose errors array carries file, line, and column for every hard failure. A CI script must treat a rejected promise as a non-zero exit — do not swallow it — and should decide deliberately whether warnings are fatal. The pattern below runs the production build, prints the metafile analysis for the build log, and exits non-zero on any error, so the pipeline gate is unambiguous.
// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
try {
const result = await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
format: 'esm',
outdir: 'dist',
minify: true,
sourcemap: 'linked',
metafile: true,
logLevel: 'warning', // surface warnings without info-level noise
});
// Fail the pipeline if any warning is present and policy says so
if (result.warnings.length > 0) {
console.error(`${result.warnings.length} warning(s); failing per policy`);
process.exit(1);
}
console.log(await esbuild.analyzeMetafile(result.metafile));
} catch {
// esbuild already printed formatted errors at logLevel 'warning'
process.exit(1);
}
Two operational points matter beyond the exit code. First, pin the esbuild version in package-lock.json and install with npm ci, not npm install, so a patch release of esbuild cannot silently change output between the branch that passed review and the deploy. esbuild’s minifier and code generator are deterministic within a version but not guaranteed identical across versions, and a byte-for-byte reproducible artifact is worth the pin. Second, do not run --watch or context() in CI; a persistent context in a batch job is pure downside — it holds a service open, complicates the exit path, and buys nothing because there is no second build to make incremental. Reserve the persistent model for the dev machine and the running server.
When not to reach for esbuild
esbuild is the wrong tool in three concrete situations, and naming them saves a wasted afternoon. First, when you need type checking as part of the build: esbuild erases types without validating them, so a build that must reject type errors needs tsc (or a type-aware bundler wrapper) in the loop — esbuild alone will happily emit code that does not type-check. Second, when you depend on the rich transform ecosystem: esbuild has plugins but no Babel-style AST transform API, so a macro system, an experimental decorator transform, or a codemod-at-build-time expressed as a Babel plugin has no direct equivalent, and porting it is real work. Third, when your output target is legacy: esbuild will down-level modern syntax, but it does not ship polyfills and its lowest practical target is broadly ES2015-era, so a build that must run on genuinely old engines still needs a polyfill and transform stack that esbuild does not replace. In each case the honest move is to let esbuild do the fast transpilation and bundling it excels at and delegate the part it does not cover to the tool built for it, rather than bending esbuild into a role it was explicitly scoped out of.
Comparison with tsc and Babel
Against tsc, the trade is speed for safety: esbuild transpiles the same TypeScript perhaps one to two orders of magnitude faster, but tsc is the only one of the two that understands the type system it is compiling. The productive arrangement is not to choose one but to split responsibilities — esbuild emits the JavaScript on every save and every build, and tsc --noEmit runs as a separate check whose failure blocks merge but never blocks the artifact. Against Babel, the trade is speed and integration for extensibility: Babel’s plugin model exposes the full AST and a mature transform ecosystem, while esbuild exposes resolution and load hooks but keeps its parser and code generator closed for the sake of the performance it buys. If your build needs nothing but “strip types, compile JSX, target modern browsers, bundle,” esbuild does it faster and with less configuration; the moment your build depends on a specific Babel transform or a tsc type gate, esbuild becomes one stage in a pipeline rather than the whole pipeline. Sizing that boundary correctly is the difference between a build that stays fast and one that quietly grows a second, slower toolchain around esbuild to compensate for what it deliberately does not do.
Compatibility matrix
--watch/--serve create a context internally.| Capability | API | CLI flag | esbuild | Node | Note |
|---|---|---|---|---|---|
| One-shot build | build() |
esbuild --bundle |
0.25.x | 18+ | stateless |
| Incremental rebuild | context() + ctx.rebuild() |
--watch |
0.18+ | 18+ | replaces watch: true |
| Dev server | ctx.serve() |
--serve |
0.17+ | 18+ | needs a context |
| Single-file transform | transform() |
--loader=ts via stdin |
0.25.x | 18+ | ignores tsconfig.json |
| Metafile analysis | analyzeMetafile() |
--analyze |
0.25.x | 18+ | reads --metafile output |
Related
- esbuild & Turbopack Workflows — where the native API sits in the wider toolchain.
- Using esbuild transform API for TypeScript stripping — the stateless single-file conversion path.
- Reducing esbuild bundle size with minify and tree-shaking — shrink output and verify it with the metafile.
- Using esbuild context watch mode for incremental rebuilds —
context(),watch(),serve(), and clean disposal. - Custom Loaders and Asset Handling — plugin-driven asset routing on top of the resolution model above.