Integrating esbuild with Framework Toolchains

Modern frontend frameworks no longer treat the bundler as one monolithic execution engine; they delegate discrete compilation phases — dependency pre-bundling, source transpilation, minification — to whichever tool is fastest at each. This guide covers the integration layer: embedding esbuild into Vite, tsup, Remix, and Angular pipelines without breaking HMR, SSR routing, or asset resolution. It sits under esbuild & Turbopack Workflows, which frames where esbuild’s Go-native engine fits against Turbopack’s incremental model. Get the phase boundaries wrong and you double-transpile; get them right and cold builds drop 3–5x with byte-identical output.

The problem this article exists to solve is that “use esbuild” is not a single decision. A framework toolchain is a directed graph of transform passes, and esbuild can be slotted into any subset of them: it can pre-bundle your node_modules, strip types from your source, minify the production output, or all three at once — but each of those is a different esbuild invocation with its own option object, its own cache, and its own failure surface. The reason teams end up with slow builds, mysterious require is not defined crashes, or hydration warnings that only appear in production is almost always that they reached for the wrong knob: they set target on the source transformer expecting it to change how node_modules were pre-bundled, or they enabled minification globally and broke a legacy syntax-lowering path. Understanding the phase boundaries is not academic — it is the difference between a config that composes and one that silently fights the framework.

What breaks without the fix is subtle and expensive. If esbuild transpiles a file that Babel or the framework’s own loader also transpiles, you pay for both passes on every keystroke in dev and every file in build, and you can corrupt source maps so stack traces point at the wrong line. If esbuild pre-bundles a CommonJS package that the framework then tries to pre-bundle again, you can ship two copies of a runtime — react/jsx-runtime is the classic offender — inflating the bundle and, in the case of React, occasionally producing “invalid hook call” errors from two React instances. And because esbuild does no type-checking, a toolchain that leans on it for transpilation without a parallel tsc pass will happily emit code that does not type-check, so a broken build reaches CI green. The rest of this guide maps each phase to the exact configuration surface that governs it, in each of the four frameworks, so you can reason about what a given key actually does before you set it.

Where this sits in the build pipeline: esbuild is almost never the orchestrator. Vite, Rollup, tsup, Remix’s Vite plugin, and Angular’s application builder all own the module graph, the plugin ordering, the dev-server socket, and the manifest emission. esbuild is called by them as a fast leaf operation — a transform-in, transform-out function that knows nothing about routes, HMR boundaries, or SSR data loading. That separation is the whole reason it is fast (no plugin dispatch per file, one Go pass over the AST) and also the reason it is limited (no cross-module type information, no incremental cache of its own beyond what the caller wires up). Treat it as a compiler primitive the framework rents, not as the framework’s replacement.

How esbuild slots into framework toolchains Four framework pipelines — Vite optimizeDeps, tsup, Remix, and Angular — each delegating specific compilation phases to esbuild while keeping routing and bundling under framework control. esbuild as a delegated phase, not the whole pipeline esbuild Go engine Vite optimizeDeps + dev transform tsup lib bundling + .d.ts emit Remix / RR7 server + browser esbuild bundle Angular esbuild application builder + Vite dev server phases delegated: pre-bundle · TS/JSX strip · minify framework keeps: routing · HMR sockets · SSR data loading
Figure: esbuild is delegated specific compilation phases by each framework's build orchestrator; the framework retains routing, HMR, and SSR control.

Prerequisites

Pin exact versions — esbuild’s plugin API and Vite’s esbuildOptions surface both shift between minors.

// package.json — verified toolchain
{
  "devDependencies": {
    "esbuild": "0.25.0",        // Go-native bundler/transformer
    "vite": "5.4.x",            // uses esbuild for deps + minify
    "@rollup/plugin-esbuild": "6.2.x",
    "tsup": "8.3.x",            // esbuild + rollup-plugin-dts wrapper
    "typescript": "5.5.x"
  }
}

Node 20.11+ is the floor; esbuild 0.25 drops support for Node < 18, and Vite 5 refuses to boot under Node 16. Run node -v && npx esbuild --version before debugging anything — a stale 0.19 binary hoisted by a monorepo is the most common phantom failure.

The version-pinning discipline matters more with esbuild than with most dev dependencies because esbuild ships as a platform-specific native binary and its transform output is not guaranteed stable across minors. Between 0.19 and 0.25 the default handling of useDefineForClassFields, the lowering of using declarations, and the exact hashing of chunk names all changed. If two packages in a monorepo resolve two different esbuild binaries — say the workspace root has 0.25 but an old library still pins 0.17 — you will get output that differs by machine depending on which copy npm or pnpm happened to hoist, and that shows up as non-reproducible content hashes and CDN cache misses that nobody can explain. Pin the exact patch, not a caret range, and add a resolutions (Yarn) or overrides (npm/pnpm) entry to force a single copy across the whole tree.

The jsonc block above uses x in the patch position for readability, but in a real lockfile-backed repo you want the fully resolved version recorded. The fast check is npm ls esbuild (or pnpm why esbuild): if it prints more than one version, dedup before you touch any config, because every other symptom in this guide can be a downstream effect of a duplicated binary. A one-line CI assertion — fail the build if npm ls esbuild --depth=Infinity reports more than one resolved version — pays for itself the first time a transitive dependency bumps its pin.

A single hoisted esbuild binary versus a duplicate When one esbuild version is hoisted to the workspace root every framework resolves the same binary and transforms are deterministic; a second copy nested under a package causes non-deterministic transforms and phantom failures. one hoisted binary — deterministic esbuild 0.25.0 Vite tsup stale nested copy — phantom failure esbuild 0.19 (nested) non-deterministic transforms
Figure: npx esbuild --version confirms which copy resolves — a nested 0.19 hoisted differently per package silently changes output.

Core mechanics: where esbuild fits in modern toolchains

Frameworks isolate dependency resolution, transpilation, and minification into discrete execution phases. esbuild is fast because it does no per-file Babel plugin dispatch; it lexes and emits in a single Go pass. The trade-off is that it does not type-check and ignores most tsconfig.json semantics beyond target, jsx*, paths, and useDefineForClassFields. Understanding which phase you are overriding is the whole game — overriding the wrong one silently double-transpiles.

How it works under the hood

esbuild’s speed is not magic; it is the absence of the layers that make Babel and tsc slow. Babel parses to an ESTree AST, walks it once per plugin (each plugin a JavaScript visitor invoked through a shared traversal), and re-serializes — all in a single-threaded V8 process with garbage-collection pressure on every node allocation. esbuild instead has a hand-written lexer and parser in Go that produce a compact internal AST, applies its fixed set of transforms (type stripping, JSX desugaring, syntax lowering, constant folding, dead-code elimination) in a small number of passes, and prints bytes — with parallelism across files bounded by GOMAXPROCS. There is no plugin visitor dispatch in the hot path; esbuild plugins only intercept resolve and load, never the per-node transform, which is precisely why a plugin cannot change how a given syntax is lowered. When you set target: 'es2017', esbuild consults an internal feature-support table keyed by the target, and for each unsupported syntax feature it either applies a built-in lowering or refuses with a clear error. It does not consult your tsconfig’s lib, does not resolve declaration files, and does not error on a type mismatch — it will strip : SomeType annotations without ever loading SomeType.

Because transpilation is per-file and stateless, esbuild has no notion that two files import the same symbol; that cross-module reasoning belongs to whoever owns the bundle graph (Rollup inside Vite build, esbuild’s own bundler in pre-bundle mode, Rollup inside tsup). This is the mechanical reason the phases have different cadences: type-stripping a single file is a pure function that can be cached by content hash, but deduplicating a shared runtime requires the whole graph. Keep that boundary in mind whenever a symptom involves duplication or ordering — those are graph problems and no per-file transform option will fix them.

Three delegated phases and their caches Pre-bundle runs once per dependency hash and is cached under node_modules/.vite/deps; source transpilation runs per request in dev and per module in build; minification runs only on the final production build. Each phase is independently swappable. independently swappable — override only the one you mean to pre-bundleonce per dep hash.vite/deps cache transpileper request (dev)per module (build) minifyproduction buildonly
Figure: the three phases have different cadences and caches — knowing which one a config key targets prevents double-transpilation.

Execution boundary mapping

Framework Phase esbuild Role Configuration Scope
Dependency pre-bundle CJS/ESM interop, tree-shaking node_modules optimizeDeps.esbuildOptions / prebundle
Source transpilation TSX/JSX stripping, syntax lowering esbuild (top-level) / transform
Production minification Whitespace removal, identifier shortening build.minify: 'esbuild' / minify

Pre-bundling runs once per dependency hash and is cached in node_modules/.vite/deps. Source transpilation runs per-request in dev and per-module in build. Minification runs only on the final build. Each is independently swappable, which is exactly why you can keep esbuild for transpilation while routing minification to Terser for legacy targets.

The caching cadence is worth internalizing because it determines when a config change actually takes effect. The pre-bundle cache key is a hash of the resolved dependency versions, the relevant optimizeDeps fields, and the Vite/esbuild versions themselves; Vite writes it into node_modules/.vite/deps/_metadata.json and reuses the pre-bundled ESM output until that hash changes. This is why editing optimizeDeps.include sometimes appears to do nothing — the hash did not shift because Vite already had those entries, or conversely why a vite --force is required to make Vite recompute after you change something it does not fold into the key. Source transpilation, by contrast, is cached only in-memory per dev session and per-module during build, so changes to the top-level esbuild key take effect on the next request or the next vite build without a force. Minification has no cache of its own; it runs as the final Rollup output transform, so a build.minify change is picked up on the very next build. Mapping a symptom to the phase whose cache governs it tells you immediately whether you need --force, a dev-server restart, or nothing.

Configuration & CLI reference

Vite dual-environment scoping

optimizeDeps.esbuildOptions configures the dependency pre-bundler; the top-level esbuild key configures source transforms. They are separate esbuild invocations and do not share options. For the underlying API, the esbuild API and CLI for Rapid Builds guide documents every flag referenced here.

This is the single most common misconfiguration in the whole surface, so it is worth stating the mechanics precisely. When Vite starts a dev server it scans your entry HTML, follows the import graph until it hits a bare specifier that resolves into node_modules, and hands the set of such dependencies to esbuild’s bundler (not its transformer) to produce a single optimized ESM file per dependency. Every option you pass under optimizeDeps.esbuildOptionstarget, define, plugins, loader — flows into that bundling call and touches nothing but node_modules. Separately, when the browser requests one of your source modules, Vite calls esbuild’s transform API with the options from the top-level esbuild key to strip types and desugar JSX for that one file. Setting target: 'es2020' on the top-level key lowers your source but leaves the pre-bundled dependencies at whatever optimizeDeps.esbuildOptions.target said (default esnext). Teams routinely set one and expect the other to follow; the two never talk. If you need a uniform floor across both — for example an old Safari target that must apply to dependencies too — you must set target in both places, and even then Vite’s final build.target governs the last-mile lowering in production.

Two separate esbuild invocations in one Vite config optimizeDeps.esbuildOptions configures the dependency pre-bundler that only touches node_modules, while the top-level esbuild key configures source transforms that only touch your own files; the two invocations share no options. optimizeDeps.esbuildOptionsthe pre-bundlertouches node_modules only top-level esbuild keysource transformstouches your files only no shared options between the two invocations
Figure: the two keys look adjacent in one config object but drive separate esbuild runs — a target set on one does not apply to the other.
// vite.config.ts — Vite 5.4.x, esbuild 0.25.x
import { defineConfig } from 'vite';

export default defineConfig(({ command }) => ({
  optimizeDeps: {
    // Scope ONLY pre-bundling of node_modules
    esbuildOptions: { target: 'esnext', logLevel: 'debug' },
    include: ['@legacy/ui-kit', 'date-fns/locale'], // force CJS-heavy pkgs through esbuild
  },
  esbuild: {
    // Scope source-file transforms (dev + build)
    jsxFactory: 'React.createElement',
    jsxFragment: 'React.Fragment',
    target: 'es2020',
    // Strip dev-only tokens only on `vite build`
    drop: command === 'build' ? ['console', 'debugger'] : [],
  },
}));

Rollup plugin placement

Order matters: resolve before transform, transform before tree-shake. @rollup/plugin-esbuild replaces both @rollup/plugin-typescript and @rollup/plugin-babel for the common TS/JSX path.

The ordering is not a style preference; it follows from how Rollup runs its plugin hooks. Rollup calls each plugin’s resolveId hook to turn a specifier into an absolute path, then load to read the bytes, then transform to rewrite them, and only after the whole graph is loaded does it perform tree-shaking and chunk formation. If esbuild() sits before resolve(), the esbuild plugin’s transform hook can be handed a module whose bare imports were never resolved to real paths, so it strips the TypeScript but leaves imports Rollup then fails to follow — the failure mode is a “Could not resolve” or “Unexpected token” error that looks like an esbuild bug but is a plugin-order bug. Put @rollup/plugin-node-resolve first so every transform sees a fully resolved module. Note also that @rollup/plugin-esbuild deliberately runs esbuild in transform mode, not bundle mode: it does per-file type-stripping and lets Rollup own bundling and tree-shaking, which is why you keep Rollup’s superior chunk-splitting while getting esbuild’s parse speed. Do not set bundle: true in its options — that would make esbuild try to own the graph and collide with Rollup.

// rollup.config.js — Rollup 4.x, @rollup/plugin-esbuild 6.2.x
import esbuild from '@rollup/plugin-esbuild';
import resolve from '@rollup/plugin-node-resolve';

export default {
  input: 'src/index.ts',
  output: { dir: 'dist', format: 'esm' },
  plugins: [
    resolve(),                                  // 1. filesystem resolution first
    esbuild({ target: 'es2017', minify: false }), // 2. TS/JSX strip second
    // 3. Rollup's own tree-shake/bundle runs last
  ],
};

tsup for library bundling

tsup wraps esbuild for bundling and rollup-plugin-dts for declaration emit, which is why it is the default for publishing packages: esbuild can strip types fast but cannot emit .d.ts, so tsup runs tsc in parallel for declarations only.

The division of labor here is the thing to understand before you trust tsup with a published package. esbuild produces the JavaScript by stripping types and bundling — fast, but with zero type information carried forward. In parallel, tsup shells out to the TypeScript compiler to emit declaration files, then feeds those through rollup-plugin-dts to bundle the many emitted .d.ts files into one flat declaration per entry point. The two pipelines are independent, which has a sharp consequence: the JavaScript can build even when the types are broken, because esbuild never type-checks and the dts step runs separately. A package can therefore publish JavaScript that works alongside .d.ts files that fail to generate, or vice versa. Treat the dts build as your de-facto type-check gate in CI, or run a standalone tsc --noEmit before tsup, because a green tsup run of the JS half is not evidence the types compile. The dts step is also the slow half — it pays full tsc cost — so when build time matters, generating declarations in a separate cached job rather than on every tsup invocation is the usual optimization.

// tsup.config.ts — tsup 8.3.x
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'], // dual-publish
  dts: true,              // delegated to rollup-plugin-dts, NOT esbuild
  target: 'es2020',
  minify: true,           // esbuild minifier
  treeshake: true,
  sourcemap: true,
  clean: true,
});

Remix / React Router 7 and Angular

Remix historically shipped its own esbuild compiler (the “classic compiler”) that bundled both app/ server modules and the browser graph; React Router 7 and the Remix Vite plugin now delegate to Vite, which in turn delegates transforms to esbuild. Angular’s @angular/build:application builder uses esbuild for production bundling and a Vite-backed dev server for HMR. In both cases you do not call esbuild directly — you tune it through the framework’s define, target, and externalization options.

The migration away from Remix’s classic compiler is the source of most Remix-plus-esbuild confusion in existing repos. Under the classic compiler, esbuild built two distinct graphs — a server build for app/ route modules and a browser build — each with its own externals and its own platform, and projects wrote esbuild-flavored config or *.server.ts conventions around it. Under the Vite plugin those two graphs become Vite’s SSR and client environments, esbuild is demoted to the per-file transformer, and the old classic-compiler plugins (anything that hooked the Remix esbuild config) simply do not run. If you inherit a Remix app that still references classic-compiler options, they are dead config; the equivalent controls now live in vite.config.ts under ssr.external, define, and the plugin’s own options. Angular is the inverse story: @angular/build:application is a newer esbuild-based builder that replaced the webpack-based @angular-devkit/build-angular:browser, and the tuning surface is Angular’s builder options (define, optimization, externalDependencies) rather than an esbuild config file. In neither framework do you get an esbuild options object to edit directly; you influence esbuild only through the vocabulary the framework chooses to expose.

Platform-specific edge bundling

For separate edge/SSR entry points, drive esbuild directly so you control platform and external:

The reason to break out of the framework here is that edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge) are neither Node nor browser, and the framework’s built-in SSR target usually assumes one of those two. platform: 'neutral' tells esbuild to inject no automatic polyfills and to prefer the module/import conditions in exports maps rather than main/require, which is exactly what an edge target needs. Listing the node:* builtins in external prevents esbuild from trying to bundle Node internals that the edge runtime provides (or forbids) itself — bundling them would either fail or drag in a shim. Keep this entry point separate from your main framework build precisely so its platform, format, and external do not leak into the client or Node-SSR graphs, where they would be wrong.

// esbuild.edge.mjs — esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/edge.ts'],
  bundle: true,
  platform: 'neutral',          // no Node polyfills
  format: 'esm',                // no CommonJS wrapper
  external: ['node:fs', 'node:path', 'node:crypto'],
  target: 'es2022',
  outdir: 'dist/edge',
  metafile: true,
});

When not to delegate to esbuild

esbuild is the right default for transpilation and modern-target minification, but there are cases where delegating to it is a mistake, and naming them saves a debugging session later. Do not use esbuild’s minifier when your target is es2015 or lower and your source uses syntax it cannot fully lower for that target — some ES2020+ constructs are only parsed by esbuild, not down-levelled to ancient targets, so the “minified” output can still contain syntax an old engine chokes on. Reach for Terser or SWC there. Do not lean on esbuild for macro-style or type-directed transforms: anything that needs to read the type checker (const enum inlining across files, emit-time decorator metadata via emitDecoratorMetadata, TypeScript’s paths beyond simple aliasing) is outside esbuild’s model, because it deliberately never loads the type graph. Angular’s dependency-injection metadata is the canonical example — it needs the Angular compiler, and the esbuild-based builder wraps that compiler rather than replacing it. Finally, do not use esbuild as your type-check gate. It strips types without checking them, so a project that treats a successful esbuild transpile as “the types are fine” will ship type errors; run tsc --noEmit in parallel and gate on that.

There is also a scale threshold. esbuild rebuilds the whole graph it is given; it has no first-class persistent incremental cache across process runs the way Turbopack does. On a small-to-medium app that whole-graph rebuild is so fast it does not matter, but on a very large monorepo where a one-file change should not re-touch thousands of modules, the incremental model wins. That trade-off is the subject of Turbopack Incremental Compilation; the short version is that esbuild optimizes cold-build throughput while an incremental compiler optimizes warm-rebuild latency, and the right choice depends on which of those dominates your inner loop.

Comparison with Babel and SWC

The three transpilers occupy different points on the same axis. Babel is the most extensible — its plugin system runs arbitrary JavaScript visitors over the AST, so it can do type-directed and macro transforms — but it is single-threaded JavaScript and the slowest by an order of magnitude. SWC is a Rust rewrite of roughly Babel’s transform surface with a plugin system, so it keeps most of the extensibility while being far faster; it is what Next.js uses for its own transforms. esbuild is the least extensible of the three by design — plugins hook only resolve and load, never the per-node transform — and in exchange it is the fastest and the simplest to reason about. The practical decision rule: if you need a custom AST transform (a Babel macro, a bespoke JSX pragma rewrite, emit-time metadata), you need Babel or SWC; if you only need type-stripping, JSX desugaring, and syntax lowering to a modern target, esbuild does it faster and with fewer moving parts. Within a framework you rarely choose in the abstract — Vite ships esbuild, Next ships SWC, and swapping the default is a deliberate, well-scoped override rather than a wholesale replacement.

Step-by-step integration workflow

Six-step integration workflow Audit the esbuild version, map each phase to a config scope, apply the Vite config and start dev, verify transforms with a metafile, build and inspect the manifest for duplicates, then gate the bundle budget in CI. 1 audit--version 2 map phaseto scope 3 applyvite --debug 4 verifymetafile 5 buildmanifest 6 gate CIbudget
Figure: the version audit at step 1 and the CI budget gate at step 6 bracket the config edits that sit between them.
  1. Audit the active esbuild version: run npx esbuild --version and npm ls esbuild at the workspace root. A single resolved version is the precondition for everything that follows; mismatched hoisted copies produce non-deterministic transforms and content-hash drift between machines. If more than one version prints, add an overrides/resolutions pin and reinstall before changing any config, because a duplicated binary can masquerade as every other symptom below.
  2. Map each phase to a scope: before editing anything, name the phase whose behavior you want to change — dependency pre-bundle, source transform, or production minify — and confirm which config key governs it from the boundary table above. This step is where most wasted effort is saved: setting target on the source transformer to fix a pre-bundled dependency, or vice versa, changes nothing and sends you chasing a phantom. Edit exactly one key, for exactly one phase.
  3. Apply the Vite config above and start dev with vite --debug. Watch the optimizeDeps log line: it prints the dependencies Vite decided to pre-bundle and the resolved include list, so you can confirm your include entries actually took and that no unexpected package is being pre-bundled. If a package you expected is absent, Vite already inlined it or could not resolve it — that distinction points you at the next step.
  4. Verify transforms with a direct esbuild call: esbuild src/index.ts --bundle --logLevel=debug --metafile=meta.json. Running esbuild outside the framework isolates whether a transform problem is esbuild’s or the framework’s wiring; the debug log shows each resolve and load decision, and the metafile records exactly which inputs fed which outputs. If the standalone run is clean but the framework build is not, the fault is in the integration layer, not esbuild.
  5. Build and inspect the metafile: run vite build, then read dist/.vite/manifest.json (or your esbuild metafile) and look for the same module resolved into more than one output chunk. Duplicate runtime inclusions — react, react/jsx-runtime, a state library — are the highest-value thing to catch here because they inflate every page that loads two of the affected chunks and can cause runtime identity bugs. The metafile is the source of truth; do not eyeball chunk names.
  6. Gate in CI with the budget script in the next section so a regression in chunk count or total size fails the pipeline instead of reaching production. Wire it to the same metafile/manifest you inspected in step 5, so local investigation and the CI gate read identical data.

Debugging & failure modes

Four integration failure modes and their fixes Could not resolve during pre-bundle is fixed by adding the package to optimizeDeps.include; require is not defined in edge bundles is fixed by format esm and platform neutral; SSR hydration mismatch is fixed by pinning process.env.NODE_ENV via define; duplicate jsx-runtime is fixed by enabling splitting. Could not resolve (pre-bundle)→ optimizeDeps.include + --force require is not defined (edge)→ format esm + platform neutral SSR hydration mismatch→ define process.env.NODE_ENV duplicate jsx-runtime→ splitting: true
Figure: each failure maps to exactly one config knob — the metafile is where you confirm which one applies.

Could not resolve during pre-bundle

Symptom: vite throws Could not resolve "<pkg>" while optimizing dependencies, often for a package that works fine when imported at runtime. Root cause: the dependency is CommonJS or uses dynamic requires that Vite’s static scanner cannot follow, so it never gets added to the pre-bundle entry set, and the first real import then fails to resolve the ESM-interop wrapper Vite expected. Fix: list the package explicitly in optimizeDeps.include so Vite forces it through esbuild’s CJS-to-ESM conversion regardless of whether the scanner found it. Confirm the fix by running vite --force, which discards node_modules/.vite/deps and its _metadata.json so Vite recomputes the pre-bundle from scratch — without --force a stale cache can hide whether your include change actually resolved the package. If it still fails, the package likely has a broken exports map, and you need optimizeDeps.esbuildOptions.mainFields or a resolve alias.

require is not defined in edge bundles

Symptom: the bundle builds cleanly but throws require is not defined at runtime the moment an edge Worker executes it. Root cause: something in the graph emitted a CommonJS require call into output that runs in a runtime with no CommonJS loader — either esbuild wrapped a dependency as CJS because format was left at its default, or platform defaulted to node and esbuild assumed require would exist. Fix: set format: 'esm' so no require wrapper is emitted, and platform: 'neutral' so esbuild stops assuming Node globals and prefers the import/module export conditions. Confirm by scanning the metafile inputs for any node:* shim or CJS entry that slipped in; if one is present, add it to external and re-check. The metafile is decisive here because the offending require is usually inside a transitive dependency you did not write.

SSR hydration mismatches

Symptom: the client console logs a hydration mismatch or React renders different markup than the server sent, intermittently and often only in production. Root cause: esbuild evaluates process.env.NODE_ENV-guarded branches based on whatever define (or lack of it) was in effect for each environment, and if the server build and client build disagree on that value, dead-code elimination keeps different branches on each side — the server ships development markup while the client expects production, or vice versa. Fix: pin the value identically for both builds with define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) }, and make sure the same define is applied to the SSR environment and the client environment. Confirm by grepping both output bundles for process.env.NODE_ENV; if the string survives in either, the define did not apply there. This is the same class of bug covered in depth by the SSR guides under Vite SSR and SSG integration.

Duplicate react/jsx-runtime across chunks

Symptom: bundle size is larger than expected and, in the worst case, React throws “invalid hook call” or components silently fail to update because two React instances exist. Root cause: the shared react/jsx-runtime module ended up copied into more than one output chunk instead of being hoisted to a shared chunk, so different parts of the app close over different module instances. Fix: inspect metafile.outputs and find every chunk that lists react/jsx-runtime among its inputs; if there is more than one, the code-splitting boundary is wrong. Set splitting: true so esbuild emits shared modules into a common chunk, and remove any minifyIdentifiers: false override, which defeats the identifier-based dedup that lets the bundler recognize the shared module. Confirm by rebuilding and re-reading the metafile: the runtime should now appear in exactly one chunk that the others import.

Performance impact & measurement

Swapping the default minify: 'esbuild' for Terser adds ~1.2s to a 50-file build but is the only safe path for es2015/IE11 syntax lowering, since esbuild does not lower all ES2020 syntax for that target. Keeping esbuild minification cuts production build time ~40% at identical gzip size for es2020+ targets. Measure with vite build --profile (writes a V8 CPU profile) and compare dist/.vite/manifest.json byte totals across runs. Pin --concurrency=1 in CI for reproducible hashes; parallel plugin execution is the usual cause of cross-runner hash drift. For incremental large-monorepo workloads where cache invalidation dominates, weigh Turbopack Incremental Compilation against esbuild’s whole-graph rebuild.

The reason the esbuild-versus-Terser trade-off exists at all is that the two minifiers do different amounts of work. esbuild’s minifier does whitespace removal, identifier shortening, and a bounded set of safe simplifications in the same single Go pass that does everything else, so its marginal cost over transpilation is small. Terser is a JavaScript-based optimizer that runs many more passes — property-name mangling, more aggressive constant propagation, sequence coalescing — and it can lower syntax esbuild leaves alone, which is why it remains the escape hatch for genuinely old targets. The gzip sizes come out close because gzip already collapses most of the whitespace and repeated tokens that separate the two; the wins Terser has over esbuild are usually in the low single-digit percent, rarely worth the build-time cost unless you must reach es2015. Measure before switching, because on a modern target the extra Terser time buys almost nothing.

When you profile, hold the inputs fixed. A fair comparison changes exactly one variable — the minifier — with the same target, the same dependency versions, and a warm-then-cold cache convention so you are not comparing a cold esbuild run against a warm Terser run. vite build --profile opens a V8 inspector session and writes a .cpuprofile you can load in Chrome DevTools to see whether time is actually going to minification or somewhere else entirely (dependency pre-bundle and source-map generation are common surprises). If minification is not the top of the profile, swapping minifiers is the wrong lever.

esbuild minify versus Terser trade-off esbuild minification is about 40 percent faster at identical gzip size for es2020-plus targets, while Terser adds roughly 1.2 seconds on a 50-file build but is the only safe path for es2015 and IE11 syntax lowering. same gzip size — different build time and target reach esbuild fast · es2020+ only Terser +1.2s · reaches es2015 / IE11 → build time (relative)
Figure: keep esbuild for modern targets; reach for Terser only when you must lower to es2015.

CI bundle-budget enforcement

A bundle budget is worth enforcing in CI because bundle regressions are invisible in code review — a single new import can pull a heavy transitive dependency into a shared chunk, and no diff makes that obvious. The script below reads the same esbuild metafile you inspect locally, so the gate and your investigation share one source of truth. It enforces three independent limits: a per-chunk ceiling that catches one file ballooning, a total-size ceiling that catches slow aggregate creep, and a chunk-count ceiling that catches accidental over-splitting (too many chunks means too many requests, the opposite failure from too-large chunks). Each is a separate process.exit(1) so the failure message tells you which limit tripped rather than a single opaque “budget exceeded”. Wire it as a required check after vite build, and set the numbers from your current measured sizes plus a small headroom, not from round guesses — a budget you have to keep raising teaches the team to ignore it.

// scripts/validate-bundle.mjs — Node 20+
import { readFileSync } from 'node:fs';

const metafile = JSON.parse(readFileSync('./build-meta.json', 'utf-8'));
const BUDGETS = { maxChunkSize: 250_000, maxTotalSize: 800_000, maxChunkCount: 12 };

let totalSize = 0;
const chunkCount = Object.keys(metafile.outputs).length;

for (const [filePath, output] of Object.entries(metafile.outputs)) {
  totalSize += output.bytes;
  if (output.bytes > BUDGETS.maxChunkSize) {
    console.error(`Budget exceeded: ${filePath} (${(output.bytes / 1024).toFixed(1)}KB)`);
    process.exit(1);
  }
}
if (totalSize > BUDGETS.maxTotalSize) {
  console.error(`Total bundle exceeds ${BUDGETS.maxTotalSize / 1024}KB`);
  process.exit(1);
}
if (chunkCount > BUDGETS.maxChunkCount) {
  console.error(`Too many chunks: ${chunkCount} > ${BUDGETS.maxChunkCount}`);
  process.exit(1);
}
console.log(`Bundle OK: ${chunkCount} chunks, ${(totalSize / 1024).toFixed(1)}KB total`);

Compatibility matrix

Which tools emit type declarations Of the five integrations, only tsup emits d.ts files directly via rollup-plugin-dts and Angular emits via tsc; Vite, the rollup esbuild plugin, and Remix via Vite do not emit declarations, so a separate declaration step is required. esbuild strips types fast but cannot emit .d.ts — who fills the gap Viteno .d.ts tsuprollup-plugin-dts rollup-esbuildno .d.ts Angularvia tsc Remix/RR7n/a Common conflict: a hoisted esbuild 0.19 in monorepos overrides the pinned 0.25. Min Node ranges 18.0 (tsup, rollup plugin) to 20.11 (Vite 5.4).
Figure: only tsup and Angular produce declarations in-band — the others need vite-plugin-dts or a standalone tsc pass.
Tool esbuild integration Min Node .d.ts emit Known conflict
Vite 5.4.x deps + dev transform + minify 20.11 no (use vite-plugin-dts) hoisted esbuild 0.19 in monorepos
tsup 8.3.x bundle + minify 18.0 yes (rollup-plugin-dts) dual CJS/ESM exports map drift
@rollup/plugin-esbuild 6.2.x transform only 18.0 no plugin order before resolve()
Angular 18 (application) prod bundle, Vite dev 18.19 via tsc Zone.js patch + define
Remix Vite / RR7 via Vite → esbuild 20.0 n/a classic-compiler-era plugins

Read the matrix as a map of who owns declarations, because that is where these integrations diverge most sharply. Vite and @rollup/plugin-esbuild emit no .d.ts at all — esbuild strips types and never reconstructs them — so a library built with either needs a separate declaration step (vite-plugin-dts, or a standalone tsc --emitDeclarationOnly). tsup fills the gap in-band by delegating to rollup-plugin-dts, and Angular emits declarations through the Angular compiler’s tsc path, which is why those two are self-sufficient for publishing. Remix and React Router 7 are application frameworks, not library builders, so declaration emit is not applicable — you are shipping a server and a client bundle, not a typed package. The “known conflict” column is the other thing to internalize: every row’s most common failure has a different shape, from the monorepo hoisting problem that afflicts Vite, to the dual CJS/ESM exports map drift that bites tsup publishers, to the Zone.js patching order Angular’s define interacts with. Match your symptom to the row before you assume the fault is esbuild’s — in most of these it is the integration seam, not the engine.

In-Depth Guides