JAVASCRIPT / MODULES AND DEVELOPER TOOLING
Bundlers, transpiling, and shipping to browsers
Explain what a bundler and a transpiler each do to your module graph, and decide what actually needs a build step before code reaches a browser.
What you will learn
- Read bundler output: module factories, an id-keyed cache, and a small require runtime
- Tell syntax lowering apart from missing runtime APIs that need a polyfill
- Trace an import graph to see why a shared dependency is included once
- Choose browser targets first, then let the build decide how much to rewrite
Understanding Bundlers, transpiling, and shipping to browsers
Modern browsers run ES modules natively, so bundling is not about making `import` work — it is about the loading model. A page that pulls in four hundred small files pays per-request overhead and discovers deep dependencies only after each parent file has arrived, one round trip per level of the graph. On top of that, `import "date-fns"` is a bare specifier: the browser has no rule that turns a package name into a URL, while a bundler knows to look in `node_modules`. So a bundler starts at your entry, follows every static import, and emits a few files plus a tiny runtime that resolves module ids at run time.
Transpiling is a different job: source-to-source rewriting of syntax a target engine cannot parse. Optional chaining, class fields and `async` functions get replaced by older equivalents, and because the rewrite must preserve behaviour it introduces temp variables and helper functions rather than a naive textual swap. The hard limit is that a compiler can only rewrite syntax it can see; it cannot invent `Array.prototype.at`, `structuredClone` or `Object.fromEntries`, because those are runtime objects, not grammar. Missing APIs are supplied by polyfills, which are just ordinary code that ships in your bundle and costs bytes.
The useful mental model is that a build is a function from (entry files, config, browser targets) to a directory of static assets, and the browser only ever sees the outputs. That is why source maps matter — without them you step through generated code — and why content-hashed filenames let you cache for a long time, since any change produces a new name and therefore a new URL. It also explains why dev and production builds differ: dev optimises for rebuild speed and readable output, production for minification, code splitting and caching.
// A bundler rewrites each source file into a factory function and adds a runtime.
const modules = {
"./math.js": (exports, __require) => {
console.log("evaluating ./math.js");
exports.double = (n) => n * 2;
},
"./main.js": (exports, __require) => {
const math = __require("./math.js");
console.log("double(21) =", math.double(21));
},
};
const cache = {};
function __require(id) {
if (id in cache) return cache[id].exports;
const mod = (cache[id] = { exports: {} });
modules[id](mod.exports, __require);
return mod.exports;
}
__require("./main.js");
__require("./main.js"); // already cached, so the factory does not run again
console.log("modules evaluated:", Object.keys(cache).length);A build step is two separate jobs — bundling turns your import graph into a few loadable files, and transpiling rewrites syntax for older engines — and neither one can supply a runtime API the browser is missing.
Worked examples
Walking the import graph
Shows the dependency-first order a bundler emits and why a shared file appears only once.
const graph = {
"main.js": ["cart.js", "format.js"],
"cart.js": ["format.js"],
"format.js": [],
};
const order = [];
const seen = new Set();
function visit(file) {
if (seen.has(file)) return;
seen.add(file);
for (const dep of graph[file]) visit(dep);
order.push(file);
}
visit("main.js");
console.log(order.join(" -> "));
console.log("format.js copies:", order.filter((f) => f === "format.js").length);Example explained
Line 1`visit` recurses into dependencies before pushing the file, so every module is emitted after the things it needs.
Line 2The `seen` set is what dedupes: `format.js` is reached from two parents but included once.
Line 3Only static imports can be walked this way, which is why bundlers care about import statements being analysable.
Line 4The final order is the order the module factories are registered in a real bundle.
What lowering optional chaining looks like
Compares modern syntax with the shape a transpiler emits, and shows the left side is still evaluated once.
const config = { server: { port: 8080 } };
const modern = (cfg) => cfg?.server?.port;
const lowered = (cfg) => {
if (cfg === null || cfg === undefined) return undefined;
const tmp = cfg.server;
if (tmp === null || tmp === undefined) return undefined;
return tmp.port;
};
console.log(modern(config), lowered(config));
console.log(modern(null), lowered(null));
console.log(modern({}), lowered({}));
let reads = 0;
const source = () => {
reads++;
return config;
};
console.log(source()?.server?.port, "reads:", reads);Example explained
Line 1`cfg?.server?.port` short-circuits to `undefined` at the first null or undefined link instead of throwing.
Line 2`lowered` is the shape a compiler produces: explicit null checks plus a temp variable.
Line 3The temp variable exists so `cfg.server` is read once; the last line proves the same rule for `source()`.
Line 4Identical results for all inputs is the point — lowering changes syntax, never semantics.
Polyfill, not transpile
Demonstrates that an absent runtime method can only be fixed by shipping an implementation.
// Stand-in for an engine that never implemented Object.entries.
const oldEngine = { Object: { keys: Object.keys } };
function entriesOf(host, obj) {
if (typeof host.Object.entries !== "function") {
host.Object.entries = (o) => host.Object.keys(o).map((k) => [k, o[k]]);
console.log("polyfilled Object.entries");
}
return host.Object.entries(obj);
}
console.log(JSON.stringify(entriesOf(oldEngine, { port: 8080, tls: false })));
console.log(JSON.stringify(entriesOf(oldEngine, { port: 8080 })));Example explained
Line 1The `typeof ... !== "function"` feature test means the polyfill installs at most once, so the message prints once.
Line 2The replacement is built out of `Object.keys`, an API the old engine does have — that is the whole trick.
Line 3No compiler could produce this: `Object.entries(x)` is already valid ES5 syntax, just a missing method.
Line 4Because the polyfill is ordinary code, it lands in your bundle and adds bytes for every visitor.
Important notes
Bundling, transpiling and minifying are three distinct jobs that one tool often performs in one pass; a bundle is not automatically minified, and minifying a file merges nothing.
Source maps are what make a bundle debuggable, but publishing them also publishes your original source, so decide deliberately whether they are public or uploaded to an error tracker only.
Common mistakes
Loading unbuilt source in a browser with `import "lodash-es"`: the page dies with "Failed to resolve module specifier" because a bare package name means nothing without a bundler or an import map.
Expecting the transpiler to fix `arr.at(-1)` on an old browser: that line is already valid ES5 syntax, so nothing is rewritten and it throws `TypeError: arr.at is not a function` at run time.
Editing `src/` while the page still serves a previously built `dist/`: you debug stale output and conclude a correct fix did not work.
Try it yourself
Change, predict, then run
Paste the main example into a browser console, add a third module `./log.js` that both `./math.js` and `./main.js` require, and increment a counter inside its factory to confirm it reaches 1 rather than 2. Then delete the cache lookup line in `__require` and watch the counter and the printed output change.
Open the JavaScript workspaceCheck your understanding
A page built with `--target=es2019` works on your machine but throws `TypeError: arr.at is not a function` on an older browser. What is the actual cause?
- The bundler dropped the module that defines `at`, so rebuilding with `--bundle` will include it.
- Lowering the target to `es5` fixes it, because then every feature is compiled down to something old browsers understand.
- Syntax was lowered, but `Array.prototype.at` is a runtime method no compiler can generate; it needs a polyfill.
- Source maps are missing, so the reported error is misleading and the real bug is somewhere else.
Show answer
`arr.at(-1)` is a plain method call that already parses under ES5, so no target setting can create the method — only shipping an implementation does. Dropping the target to `es5` is the tempting answer because it sounds maximally compatible, but it only rewrites syntax forms such as arrow functions and `async`, leaving the missing method exactly as missing while inflating the bundle.