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.

esbuild API surface and process lifecycle build, context, and transform entry points mapped to their process lifecycle from initialize through dispose. esbuild.build() one-shot graph build resolves + bundles CI / production esbuild.context() persistent build watch() / serve() incremental rebuilds esbuild.transform() single string in/out no fs, no resolution resolve graph parse + bundle in-memory cache rebuild() on change strip TS / JSX emit code + map outputs dist + metafile code + warnings ctx.dispose() free watchers skip → heap leak
Figure: build, context, and transform share a parser but diverge on lifecycle — only context retains state and must be disposed.

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" in package.json.
  • A reproducible entry point such as src/index.ts. esbuild does not read tsconfig.json for the transform() API and only honors a subset (paths, target, jsx*) for build(), so do not assume your tsconfig flags 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.

Local install, Node 20, limited tsconfig honoring Install esbuild locally not globally to avoid flag-parity drift, run on Node 20 or newer, and remember that transform reads no tsconfig while build honors only a subset such as paths, target and jsx. local installnot global — flag parity Node 20+ESM + CJS entry tsconfig barely honoredtransform reads none Don't assume tsconfig flags carry over
Figure: the tsconfig caveat is the surprise — 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.

Three entry points, three lifecycles build is a stateless one-shot ideal for CI; context is a persistent build that retains the module graph for incremental rebuilds and must be disposed; transform is a stateless single-string pass with no filesystem access. build()one-shot, statelessCI / production context()persistent, retains graphmust dispose() transform()single string in/outno fs, no resolution Choosing the wrong entry point causes most perf and correctness complaints
Figure: only 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 --inspect and capture heap snapshots before and after ctx.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 its rebuild(), or, if you genuinely need per-request contexts, to await ctx.dispose() in a finally block. Confirm the fix by taking a snapshot, forcing several reloads, taking another, and checking that the number of context objects is stable rather than growing.
  • Process exit verification: Pass --log-level=debug to 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 a node process 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 your SIGINT handler: 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 first SIGINT with exit code 0.
  • Orphaned handle tracing: process.getActiveResourcesInfo() (Node 18.7+) reveals lingering FSWatcher or TCPSocketWrap instances from a serve context that outlived its dispose call. Call it right before you expect the process to exit; any FSWatcher entries mean a watch() context is still open, and TCPSocketWrap entries mean a serve() 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 strips syntax; build resolves the graph transform is a pure lexer-plus-codegen pass that strips TS and JSX with no filesystem access and no import resolution; reach for build with bundle false the moment you need import resolution or multi-file output. transform()strip TS/JSX, one stringno fs, no imports, no .d.ts build({ bundle: false })resolves importsmulti-file output The moment you need resolution, transform() is the wrong tool
Figure: 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.

Four CLI flags for production output --splitting generates shared chunks and needs --format=esm and --outdir; --metafile emits an audit JSON; --minify combines syntax, whitespace and identifier minification; and --sourcemap=linked ships maps for symbolication. --splittingshared chunksneeds esm + outdir --metafileaudit JSON graphbyte sizes --minifysyntax + ws + idssplit for readable ids --sourcemaplinkedsymbolication
Figure: --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=esm and --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=esm there is no import statement to emit for the shared chunk, so the flag is silently a no-op; without --outdir there 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 for chunk-*.js files in the output and cross-referencing the metafile’s outputs map.
  • --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 in inputs records a file’s byte size and its list of imports with the resolved path and import kind, and each entry in outputs records 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 (collapsing if/else into 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-identifiers so error reports stay readable, and accept the marginal size increase.

Step-by-step: a deterministic build-and-verify loop

Six-step build-and-verify loop Install pinned esbuild, author the entry build, run it and confirm exit zero, inspect the metafile with analyzeMetafile, switch to a context with watch for dev, then dispose on shutdown so the Go subprocess exits. 1 pin install0.25 2 author build--metafile 3 runexit 0 4 analyzemetafile 5 context+watchdev 6 disposeSIGINT/TERM
Figure: the loop ends in dispose() — the step that separates a clean exit from a leaked subprocess.
  1. Install pinned esbuildnpm install --save-dev esbuild@0.25 so the CLI and API agree on flag shape.
  2. Author the entry build — write the build() or CLI invocation with --bundle --format=esm --metafile=meta.json.
  3. Run the buildnode build.mjs (or the CLI line above) and confirm exit code 0.
  4. Inspect the metafilenpx esbuild --analyze=verbose < meta.json or feed meta.json to esbuild.analyzeMetafile() to print a byte-attributed tree.
  5. Switch to a context for dev — replace build() with context() + ctx.watch() for incremental rebuilds.
  6. Dispose on shutdown — wire SIGINT/SIGTERM to ctx.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.

Two-phase resolution for a virtual module Phase one onResolve intercepts a virtual import and assigns a namespace; phase two onLoad injects the content for that namespace; forgetting the namespace key is why injected modules silently fall through to the filesystem. onResolve → namespacephase 1: path mapping onLoad → contentsphase 2: content injection no namespace keyfalls through to fs
Figure: a missing 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 .tsx and .ts share a base name; explicit ordering removes it. The symptom is that an import of ./widget resolves to widget.js when you expected widget.ts, because the default extension order lists .tsx, .ts, .jsx, .js, .css and stops at the first hit, but a stale compiled .js sitting 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 metafile inputs for which path actually resolved.
  • Loader mapping validation: Verify --loader:.png=dataurl or --loader:.svg=text behavior 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 the file loader, 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 onLoad callback only fires for the namespace its filter declares; forgetting the namespace key 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 an onResolve that assigned a namespace paired with an onLoad whose filter omitted that same namespace, so the load falls back to the default file namespace and esbuild tries to stat a path you invented. The fix is to make the namespace in onLoad match the one onResolve returned exactly; confirm by logging inside the onLoad callback 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.

Three incremental-build tuning levers Use metafile-driven audits to attribute bytes and find duplicate versions, reuse a context with rebuild instead of calling build in a loop, and profile plugin hooks because a broad filter fires for every module. metafile auditbytes per input context reuserebuild(), not build() loop plugin profilingbroad filter fires per module Context reuse is the biggest win for repeated builds
Figure: calling 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 both node_modules/foo and node_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 single context() with ctx.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 calling build() 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 single build() call, or holding one context and rebuilding, collapses that to one parse.
  • Plugin profiling: Wrap custom onResolve/onLoad hooks with performance.now(). A hook that runs on a broad filter: /.*/ fires for every module and is the usual cause of a slow incremental rebuild. The filter is 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 your performance.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

API-to-CLI capability map build maps to esbuild --bundle, context with rebuild maps to --watch and replaced the old watch:true, ctx.serve maps to --serve, and transform maps to the --loader stdin path; all need Node 18 or newer. build()--bundlestateless context()--watch / --servereplaces watch:true transform()--loader via stdinignores tsconfig analyzeMetafile--analyzeNode 18+
Figure: the CLI is a thin wrapper — --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

In-Depth Guides