Generating Source Maps with esbuild in Production
You ship an esbuild-built bundle, an exception fires in the browser, and the frame that reaches your error tracker reads chunk.abcd12.js:1:98213 with no file name and no code snippet — even though the same build symbolicates perfectly on your laptop. This how-to walks the exact esbuild sourcemap and sourcesContent decisions that turn that opaque frame back into src/checkout/total.ts:42, and does it without leaking the map to the public. It fits under Rollup & esbuild Source Map Configuration and the broader Source Map Generation & Stack Trace Debugging reference; if your paths come out wrong rather than missing, the companion how-to Fixing Rollup Source Map Paths for Monorepo Builds tackles that specific failure.
Symptom / Trigger
The tell-tale sign is a production event whose stack is entirely minified — a single line number, a large column, and a mangled function name. It looks fine locally because your dev server serves the maps inline, but the production event that lands in Sentry, Rollbar, or your own collector shows this:
TypeError: Cannot read properties of undefined (reading 'total')
at r (checkout.9f3ab2c1.js:1:98213)
at Array.map (<anonymous>)
at o (checkout.9f3ab2c1.js:1:97044)
at HTMLButtonElement.i (checkout.9f3ab2c1.js:1:41288)
The tracker sits next to it with a warning like Source map not found for checkout.9f3ab2c1.js or, worse, Missing sources content — the map was located but it cannot show you the failing line. Both mean the same thing to a debugging engineer: the frame is useless. You have the where (a column in a one-line file) but none of the what.
The trigger is almost always a build config that behaves differently between development and production. Locally you run esbuild --sourcemap or a dev server that keeps maps inline, so every stack you see in the console is already symbolicated and you never notice a gap. The production build is a separate script — often build.mjs invoked from CI — and it either omits the sourcemap option entirely or sets it to a value that hides the map so thoroughly that even your own uploader cannot find it. Because the two paths diverge, the bug is invisible until a real user hits an exception and the raw frame arrives with nothing attached.
Root Cause Explanation
esbuild does not emit source maps for production builds unless you ask it to, and the way you ask changes both whether a map exists and whether the browser can see it. A minimal production call that omits the option produces a bundle and nothing else:
// build.mjs — the broken pattern: no sourcemap, no sourcesContent
import { build } from 'esbuild';
await build({
entryPoints: ['src/checkout.ts'],
outdir: 'dist',
bundle: true,
minify: true,
// no `sourcemap` key at all -> esbuild writes ZERO .map files
});
With no sourcemap key, esbuild writes checkout.9f3ab2c1.js and stops. There is no .map to upload, so the tracker has nothing to translate the column against and every frame stays minified. The second, subtler failure is enabling a map but dropping the original code: esbuild’s sourcesContent defaults to true, but many production configs set it to false to shrink artifacts, which produces a map that resolves the file name and line yet shows a blank snippet unless the tracker can independently fetch your source. The pipeline below is the shape you actually want — the original tree flows through esbuild into a standalone external map that only your upload step consumes, never the browser.
The core mechanism: esbuild’s sourcemap option has four string values — 'linked', 'external', 'inline', and 'both' — plus the boolean shorthands. Only one of them writes a map and keeps it hidden from the browser, and getting a readable snippet on top of that requires sourcesContent to stay on. Miss either and you get a frame that is present-but-blank or absent entirely.
Step-by-Step Fix
- Turn on external maps so a standalone
.mapis written with nosourceMappingURLcomment.
// build.mjs — 'external' writes checkout.js.map and appends NO comment
await build({
entryPoints: ['src/checkout.ts'],
outdir: 'dist',
bundle: true,
minify: true,
sourcemap: 'external', // hidden from browsers; file waits in dist/ for upload
});
- Keep
sourcesContent: trueso the map embeds your original code and the tracker can render the failing line.
// build.mjs — embed original source text inside the map
await build({
entryPoints: ['src/checkout.ts'],
outdir: 'dist',
bundle: true,
minify: true,
sourcemap: 'external',
sourcesContent: true, // tracker shows the exact failing line, no repo integration needed
});
- Never use
'inline'or'both'in production — both ship the full map, and therefore your source, inside the served bundle.
// build.mjs — DO NOT ship these publicly; 'both' leaks source in the bundle
// sourcemap: 'inline', // base64 data URI bloats the bundle 2-3x AND exposes source
// sourcemap: 'both', // writes external file yet ALSO inlines it -> still public
- Add a content hash so each bundle and its map share a stable, cache-safe base name.
// build.mjs — [hash] makes the .map pair 1:1 with its chunk by name
await build({
entryPoints: ['src/checkout.ts'],
outdir: 'dist',
bundle: true,
minify: true,
sourcemap: 'external',
sourcesContent: true,
entryNames: '[name].[hash]', // checkout.9f3ab2c1.js -> checkout.9f3ab2c1.js.map
});
- Upload the maps against a release id, then delete them before the CDN sync so the
.mapnever ships.
// scripts/release.mjs — upload first; only delete if upload succeeded
import { execSync } from 'node:child_process';
import { readdirSync, rmSync } from 'node:fs';
import path from 'node:path';
const dist = path.resolve('dist');
const release = execSync('git rev-parse --short HEAD').toString().trim();
// throws (non-zero exit) on failure, so the delete below never runs on error
execSync(`npx sentry-cli sourcemaps upload --release "${release}" "${dist}"`, { stdio: 'inherit' });
for (const f of readdirSync(dist)) if (f.endsWith('.map')) rmSync(path.join(dist, f)); // strip maps
Order matters in that last script more than the individual commands do. If the delete runs before the upload — or runs unconditionally after a failed upload — you either ship a release with no maps or leave the .map files in the deploy set for the CDN to serve. The four stages below are the sequence esbuild builds hand off to: emit external maps, verify each one locally, upload against the release id, then delete before the artifacts sync out. Treat any reordering as a bug, because the failure it causes only surfaces in production where the maps are already gone or already public.
The difference sourcesContent makes is worth seeing side by side. With it off you keep file names and line numbers but the tracker’s code viewer is empty; with it on the failing expression is right there in the event.
Verification
Prove three things before you deploy: that the map is present, that no sourceMappingURL comment leaked into the bundle, and that a real column resolves to real source with its content. The first two are one-liners:
# 1. a .map exists and is non-empty for every JS chunk
find dist -name "*.map" -size +0c | wc -l # should equal your JS chunk count
# 2. the bundle carries NO sourceMappingURL comment (proves it is hidden/external)
grep -l "sourceMappingURL" dist/*.js && echo "FAIL: comment present" || echo "OK: hidden"
The definitive check resolves a known position and asserts the embedded source is there — this is what catches a map that was built with sourcesContent: false by accident:
// scripts/verify.mjs — resolve a column and require embedded content
import { SourceMapConsumer } from 'source-map';
import { readFileSync } from 'node:fs';
const raw = readFileSync('dist/checkout.9f3ab2c1.js.map', 'utf8');
const consumer = await new SourceMapConsumer(raw);
const pos = consumer.originalPositionFor({ line: 1, column: 98213 });
console.log(pos); // { source: '.../checkout.ts', line: 42, column: 8, name: 'total' }
console.log('has content:', consumer.sourceContentFor(pos.source) != null); // must be true
consumer.destroy();
if (!pos.source) process.exit(1); // fail CI on a broken map
A run that prints a real source, a real line, and has content: true proves the whole chain works. If has content is false, sourcesContent was stripped somewhere and the tracker will show blank snippets.
Edge Cases & Gotchas
minifySyntaxcan shift columns. esbuild’s AST-level syntax minification rewrites expressions, so a resolved column can land one token off. If you need pixel-precise frames, setminifySyntax: falseand keep onlyminifyWhitespaceandminifyIdentifiers.legalComments: 'external'is not the same knob. That option moves license banners to a.LEGAL.txtfile and has nothing to do with source maps — do not confuse it withsourcemap: 'external'.- The Build API and the Transform API differ.
esbuild.transform()returns the map as a string in memory and never writes a file; onlyesbuild.build()writes.mapfiles tooutdir. A watch-mode script usingtransformwill silently produce no artifacts to upload. - Renaming files after the build breaks the pairing. esbuild names the map
<chunk>.mapoff the final chunk name; if a later step re-hashes or renames the.js, the.mapno longer matches and the tracker cannot find it.
FAQ
Which esbuild sourcemap value is safe for a public production deploy?
Only 'external'. It writes a standalone checkout.js.map next to the bundle and, crucially, does not append a //# sourceMappingURL= comment, so browsers never learn the map exists and never fetch it. You upload that file to your tracker out of band and delete it before the CDN sync. 'linked' (and sourcemap: true) appends the comment and makes the map publicly fetchable; 'inline' bloats the bundle with a base64 copy; and 'both' writes the external file yet still inlines it, defeating the purpose. For anything the public can reach, 'external' is the single correct choice.
My map resolves the file and line but the code snippet is blank — what is wrong?
Your map was built with sourcesContent: false. That option controls whether esbuild copies your original source text into the map’s sourcesContent array. Without it the map still contains the mappings needed to resolve a column to a file and line, but there is no code for the tracker to display, so the viewer shows an empty box. Set sourcesContent: true (it is the default, so usually this means someone turned it off to save bytes) and rebuild. Verify with consumer.sourceContentFor(source) returning a non-null string.
Does hiding the map with 'external' mean my source is safe if sourcesContent is on?
Yes, as long as the .map file itself never reaches a public URL. 'external' prevents the browser from discovering the map, and sourcesContent: true only embeds your source inside that map file. Because your release script uploads the map to the tracker and deletes it before the deploy artifacts sync to the CDN, the embedded source never leaves your build machine as a public asset. The exposure risk is the .map being served, not sourcesContent being on — so the mitigation is the delete step and a CDN rule, not stripping your source content.
Related
- Rollup & esbuild Source Map Configuration — the parent guide covering both bundlers’ sourcemap modes, path transforms, and minification chaining.
- Fixing Rollup Source Map Paths for Monorepo Builds — fixing
outbaseandsourceRootwhen maps exist but paths never match the tracker. - Securing Hidden Source Maps from Public Access — server, CDN, and storage controls that keep external
.mapfiles private after they are generated. - Source Map Generation & Stack Trace Debugging — the reference overview of source map formats, symbolication, and upload workflows.