Combining esbuild with Terser Without Breaking Maps
Teams reach for this two-tool pipeline when esbuild’s own minifier leaves a little compression on the table: esbuild bundles in milliseconds, then Terser squeezes the output a few percent smaller with its multi-pass compressor. The trap is that once a second minifier runs after esbuild, the source map esbuild produced no longer describes the file that actually ships — Terser must be handed esbuild’s map and told to compose on top of it, or every production stack frame resolves to esbuild’s throwaway intermediate instead of your real source. This how-to sits under Rollup & esbuild Source Map Configuration and the broader reference on Source Map Generation & Stack Trace Debugging; if you would rather symbolicate the resulting map yourself, the walkthrough in Local Symbolication with Mozilla source-map Library picks up where this one ends, and Configuring Webpack for Production Source Maps covers the equivalent chaining problem in a loader-based pipeline.
Symptom / Trigger
You bundle with esbuild, pipe the result through Terser to shave a few more kilobytes, upload the final map, and every incoming error resolves not to src/checkout.ts but to a file called bundle.js that no human ever wrote — a wall of single-letter variables on line 1. The frame technically symbolicates, so the tracker reports success, but the “original” location is esbuild’s minified intermediate:
TypeError: Cannot read properties of undefined (reading 'total')
at applyCoupon (bundle.min.js:1:8421)
original: bundle.js:1:6033 <-- esbuild's intermediate, not your source
at checkout (bundle.min.js:1:8110)
original: bundle.js:1:5744 <-- should be src/checkout.ts:42:14
The giveaway is that the resolved original path ends in bundle.js (esbuild’s output name) instead of a src/... path. The map chain stopped one link short: Terser wrote a perfectly valid map from the doubly-minified file back to esbuild’s output, but nothing carried that back to your TypeScript.
What makes this failure so easy to ship is that nothing errors. The build passes, the map file is well-formed JSON with a plausible mappings string, the upload command accepts it, and the tracker even shows a snippet of code — just the wrong code. You typically discover it only when an on-call engineer opens a real incident, sees a stack full of a, b, and c variables, and realizes the “source” they are staring at is the intermediate bundle. By then the release is already live, and the map you would need to fix it retroactively no longer exists because the intermediate was thrown away.
Root Cause Explanation
A source map is a single-hop translation: it maps positions in one generated file back to one set of sources. When you run two minifiers in series you create two hops — original → esbuild output, then esbuild output → Terser output — and each tool only knows about its own hop. Terser, run naively, treats esbuild’s already-minified bundle as if it were hand-written source and emits a map whose sources array literally contains ["bundle.js"]. The original esbuild map is never consulted, so the link back to src/ is silently dropped.
// build.mjs — BROKEN: Terser has no idea esbuild ran first
import { build } from 'esbuild';
import { minify } from 'terser';
const result = await build({
entryPoints: ['src/index.ts'], bundle: true, write: false,
sourcemap: 'external', outdir: 'dist',
});
const code = result.outputFiles.find((f) => f.path.endsWith('.js')).text;
// No incoming map passed -> Terser maps back to `code`, not to src/*.ts
const out = await minify(code, { sourceMap: true });
Terser’s minify() accepts the previous map through sourceMap.content. Give it that, and Terser composes its own transformation on top, walking both hops so the final mappings field points all the way back to src/index.ts. Omit it, and you get a structurally valid map that is semantically useless. The mechanism is the same one that governs any minifier-after-bundler chain: each stage produces a map relative to its own input, and unless every downstream stage is explicitly handed the upstream map, only the last hop survives into the file you ship. Bundlers that run minification internally — as esbuild does when you pass minify: true — hide this composition from you because a single pass generates a single map. The moment you split bundling and minifying across two processes, the composition becomes your responsibility, and sourceMap.content is the one lever that performs it.
Step-by-Step Fix
The whole fix is one in-memory build script: keep esbuild’s output and map in memory, hand both to Terser, and write the composed result. Running esbuild with write: false avoids a wasteful disk round-trip and keeps the intermediate map out of your dist/ where it could accidentally ship.
- Bundle with esbuild in memory, emitting an external map and keeping
sourcesContent.
// build.mjs — step 1: esbuild, results kept in memory
import { build } from 'esbuild';
const result = await build({
entryPoints: ['src/index.ts'],
bundle: true, write: false, outdir: 'dist',
sourcemap: 'external', // produce a standalone map, no inline comment
sourcesContent: true, // embed original text so the chain can carry it forward
minify: false, // let Terser do the squeezing; esbuild only bundles
});
- Pull the generated code string and its map string out of the
outputFiles.
// step 2: locate the .js and .js.map entries esbuild returned
const jsFile = result.outputFiles.find((f) => f.path.endsWith('.js'));
const mapFile = result.outputFiles.find((f) => f.path.endsWith('.js.map'));
const esbuildCode = jsFile.text; // intermediate bundle
const esbuildMap = mapFile.text; // map A: bundle -> src
- Run Terser and pass esbuild’s map as
sourceMap.contentso Terser composes both hops.
// step 3: the critical line is sourceMap.content — it chains map A + map B
import { minify } from 'terser';
const min = await minify({ 'bundle.js': esbuildCode }, {
compress: { passes: 2 },
sourceMap: {
content: esbuildMap, // feed esbuild's map in; Terser walks it back to src
url: 'index.min.js.map', // name the reference; omit to keep it hidden
includeSources: true, // carry sourcesContent through to the final map
},
});
- Strip the trailing
sourceMappingURLcomment if you want a hidden map, then write both files.
// step 4: write the doubly-minified code and the single combined map
import { writeFileSync, mkdirSync } from 'node:fs';
mkdirSync('dist', { recursive: true });
const code = min.code.replace(/\n?\/\/# sourceMappingURL=.*$/, ''); // hide the map
writeFileSync('dist/index.min.js', code);
writeFileSync('dist/index.min.js.map', min.map); // map points straight to src/*.ts
Once sourceMap.content is in place the two-hop translation collapses into a single map whose sources reads ["src/index.ts", ...] — exactly what your error tracker expects. Because the composition happens entirely inside the Terser call, you upload only the final index.min.js.map; the esbuild intermediate never touches disk and never needs uploading. That single-file output is also what keeps the chain honest across machines: a local build, a CI build, and a container build all run the same two hops in the same process, so as long as esbuild’s sources come out repo-relative, Terser preserves them verbatim and the final map is byte-stable regardless of where it was produced.
Verification
Never trust the composed map by eye — assert that a generated column resolves to a real source path ending in .ts, not to the intermediate bundle.js. The Mozilla source-map library reads the final map and reports exactly where a position lands.
// scripts/verify-chain.mjs — prove the map chains all the way to source
import { SourceMapConsumer } from 'source-map';
import { readFileSync } from 'node:fs';
const raw = readFileSync('dist/index.min.js.map', 'utf8');
const consumer = await new SourceMapConsumer(raw);
const pos = consumer.originalPositionFor({ line: 1, column: 200 });
consumer.destroy();
// The chain is intact only if the resolved source is your real file.
if (!pos.source || /bundle\.js$/.test(pos.source)) {
console.error(`BROKEN: resolves to ${pos.source} (chain dropped a hop)`);
process.exit(1);
}
console.log(`OK: column 200 -> ${pos.source}:${pos.line}:${pos.column}`);
// Expected: OK: column 200 -> src/index.ts:42:14
A resolved source of bundle.js (or a null source) means sourceMap.content was missing or the wrong map string was passed. A src/… path with a plausible line and column means both hops composed correctly and the map is safe to upload. Wire this script in between your build and upload commands so a dropped hop fails the pipeline rather than the next production incident. Pick a column well inside the line — column 200 rather than column 0 — because column 0 of a minified bundle often maps to a banner comment or a runtime prelude that resolves cleanly even when the interesting mappings are broken, giving you a false pass. Testing a mid-line position exercises real generated code and catches a chain that dropped its second hop while still resolving the file header.
Edge Cases & Gotchas
- Filename key must match. The object key you pass to
minify({ 'bundle.js': code })becomes the entry Terser looks up in the incoming map’sfile/sources. If esbuild named its outputindex.jsbut you key it asbundle.js, Terser cannot align the hops and silently falls back to a broken single-hop map. toplevelandmangleare fine;keep_fnamesis worth it. Renaming locals never hurts the map because names come from the map, but settingkeep_fnames: truekeepsapplyCouponreadable in the raw stack even before symbolication — helpful when the map upload fails.- Do not double-minify with esbuild AND Terser both compressing. Let esbuild only bundle (
minify: false) and Terser do all compression, or esbuild’s identifier mangling makes Terser’s second pass yield almost nothing while doubling the chance of a column drift between the two hops. - Watch for a stray inline comment. esbuild’s
externalmode omits thesourceMappingURLcomment, but if you switch esbuild tolinkedthe comment survives into Terser’s input and can leave two conflicting map references in the final file — strip it before writing, as step 4 does.
FAQ
Why not just use esbuild’s built-in minifier and skip Terser entirely?
For most projects you should — esbuild’s minifier is fast and produces output within a couple of percent of Terser’s. The two-tool chain earns its keep only when bundle size is contractually tight (an embedded widget, a strict performance budget) and Terser’s multi-pass compress reclaims bytes esbuild leaves behind. When you do add Terser, the source map composition described here is mandatory; there is no shortcut that keeps the chain intact without passing sourceMap.content.
Does passing sourceMap.content slow the Terser step noticeably?
No. Terser parses the incoming map once and composes mappings as it walks its own AST transforms, which is cheap relative to the compression passes themselves. The cost is dominated by compress: { passes: 2 }, not by map composition. If your build feels slow, reduce passes before you consider dropping the map content — dropping content saves almost nothing and breaks symbolication.
What if I run esbuild through the CLI instead of the JavaScript API?
Write esbuild’s map to disk with --sourcemap=external, then read that .js.map file and feed its contents into sourceMap.content in a separate Terser script. The composition is identical; you simply load map A from disk instead of from outputFiles. Delete esbuild’s intermediate map after Terser runs so only the final combined map reaches your upload step.
Related
- Rollup & esbuild Source Map Configuration — the parent guide covering sourcemap modes, path normalization, and minifier interaction for both bundlers.
- Source Map Generation & Stack Trace Debugging — the reference overview of source map formats, chaining, and symbolication workflows.
- Local Symbolication with Mozilla source-map Library — resolve the combined map yourself instead of uploading it to a hosted tracker.
- Configuring Webpack for Production Source Maps — the same multi-tool map-chaining problem inside a loader-based build.