JavaScript (ES2026) Module 12 – Modules, packages and build tools
Bundlers, transpilers, polyfills and Baseline
What bundlers, transpilers, minifiers and polyfills each do to JavaScript, how to choose browser targets with Baseline data, and how to read the output.
What you will learn
- Explain what a bundler, a transpiler, a minifier and a polyfill each do to a program
- Decide for each new feature whether it needs a transpiler, a polyfill or neither
- Choose browser targets with Baseline statuses and browser-compat-data
- Read a build's output, its chunks, its sizes and its source maps
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what a bundler, a transpiler, a minifier and a polyfill each do to a program
- Decide for each new feature whether it needs a transpiler, a polyfill or neither
- Choose browser targets with Baseline statuses and browser-compat-data
- Read a build's output, its chunks, its sizes and its source maps
Before you start
On this page
Browsers download every file a page needs, may lack features you use, and run only syntax their engine understands.
Build tools close those gaps, and each does one job. A bundler follows your import statements from an entry
module and writes a few files instead of hundreds, leaving out code nobody calls and splitting off code that can load
later. A transpiler rewrites newer syntax, such as ?. or private #fields, into older syntax that older
engines parse. A minifier makes the result smaller. A polyfill is code that adds a missing built-in, such as
Iterator.concat(), at run time. Baseline tells you which features already work in every major browser, so you
know which of these jobs your targets need. This lesson builds a small shop app with a bundler of about a hundred
lines, then reads what a real bundler, esbuild 0.28.2, wrote for the same app.
What a bundler does
The app in app/src has four modules: main.mjs imports cart.mjs and money.mjs, and loads report.mjs with a
dynamic import() once the total is printed. money.mjs exports parseRupees and cart.mjs exports applyCoupon,
which nothing calls. This toy bundler takes the steps every real one takes, on a deliberately small subset of the
language:
toy-bundler/bundle.mjs · toy-bundler/bundle.mjs
// A bundler in about a hundred lines, to show the steps every real bundler takes: build the module graph from an
// entry, drop the code nobody uses (tree shaking), join the modules into one file in dependency order (scope
// hoisting) and give each dynamic import() a chunk of its own. It understands only the import and export forms that
// the app in ../app/src uses, with a blank line between top-level statements; Rolldown, esbuild and webpack parse
// the whole language.
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { brotliCompressSync, gzipSync } from 'node:zlib';
const STATIC_IMPORT = /^import \{([^}]+)\} from '(\.[^']+)';$/;
const DYNAMIC_IMPORT = /import\('(\.[^']+)'\)/g;
const DECLARATION = /^(export )?(?:async )?(?:function\*?|const|let|class) (\w+)/;
const name = (file) => basename(file);
// 1. The module graph: each module read once, its dependencies placed before it.
function moduleGraph(entry) {
const order = [];
const seen = new Set();
(function visit(file) {
if (seen.has(file)) return;
seen.add(file);
const lines = readFileSync(file, 'utf8').split('\n');
for (const line of lines) {
const m = STATIC_IMPORT.exec(line);
if (m) visit(resolve(dirname(file), m[2]));
}
const code = lines.filter((line) => !STATIC_IMPORT.test(line)).join('\n');
const statements = code.split(/\n\s*\n/).map((text) => text.trim()).filter(Boolean).map((text) => {
const first = text.split('\n').find((line) => !line.startsWith('//')) ?? '';
const d = DECLARATION.exec(first);
return { text, declares: d?.[2] ?? null, exported: !!d?.[1], file };
});
order.push({ file, statements });
})(entry);
return order;
}
// 2. Tree shaking: start from the statements that do something (not declarations) and keep every declaration they
// mention, and every declaration those mention, until nothing changes. The rest is dropped.
function shake(modules, keepExportsOf) {
const all = modules.flatMap((m) => m.statements);
const declared = new Map();
for (const s of all) {
if (!s.declares) continue;
if (declared.has(s.declares)) throw new Error(`${s.declares} is declared twice: a real bundler renames one of them`);
declared.set(s.declares, s);
}
const live = new Set(all.filter((s) => !s.declares || (s.exported && s.file === keepExportsOf)));
for (let grew = true; grew; ) {
grew = false;
for (const s of [...live]) {
for (const [id, decl] of declared) {
if (!live.has(decl) && decl !== s && new RegExp(`\\b${id}\\b`).test(s.text)) {
live.add(decl);
grew = true;
}
}
}
}
return { live, dropped: all.filter((s) => !live.has(s)).map((s) => `${s.declares} (${name(s.file)})`) };
}
// 3. Joining, and 4. splitting: one file per chunk, a new chunk for every dynamic import.
const chunks = [];
function bundle(entry, out, isEntry) {
const modules = moduleGraph(entry);
// A dynamically imported chunk keeps its entry module's exports: the importer reads them at run time.
const { live, dropped } = shake(modules, isEntry ? null : entry);
const parts = [];
for (const m of modules) {
for (const s of m.statements.filter((x) => live.has(x))) {
let text = s.exported && !(s.file === entry && !isEntry) ? s.text.replace(/^((?:\/\/.*\n)*)export /, '$1') : s.text;
text = text.replace(DYNAMIC_IMPORT, (match, spec) => {
const target = resolve(dirname(m.file), spec);
const file = `${name(target).replace(/\.mjs$/, '')}.chunk.mjs`;
if (!chunks.some((c) => c.out === file)) bundle(target, file, false);
return `import('./${file}')`;
});
parts.push(text);
}
}
chunks.push({ out, graph: modules.map((m) => name(m.file)), dropped, code: `${parts.join('\n\n')}\n` });
}
rmSync('out', { recursive: true, force: true });
mkdirSync('out');
bundle(resolve('../app/src/main.mjs'), 'main.mjs', true);
const sources = ['main.mjs', 'cart.mjs', 'money.mjs', 'report.mjs'].map((f) => statSync(join('../app/src', f)).size);
console.log(`source: 4 modules, ${sources.reduce((a, b) => a + b)} bytes\n`);
for (const c of chunks.reverse()) {
writeFileSync(join('out', c.out), c.code);
const bytes = Buffer.from(c.code);
console.log(`out/${c.out}`);
console.log(` modules, dependencies first: ${c.graph.join(' → ')}`);
console.log(` left out by tree shaking: ${c.dropped.join(', ') || 'nothing'}`);
console.log(` ${bytes.length} bytes, ${gzipSync(bytes, { level: 9 }).length} gzip, ${brotliCompressSync(bytes).length} brotli`);
}
const run = (file) => execFileSync(process.execPath, [file], { encoding: 'utf8' });
console.log(`\nout/main.mjs prints the same as the source: ${run('out/main.mjs') === run('../app/src/main.mjs') ? 'yes' : 'NO'}`);
console.log(run('out/main.mjs').trimEnd()); Output
source: 4 modules, 1729 bytes out/main.mjs modules, dependencies first: money.mjs → cart.mjs → main.mjs left out by tree shaking: parseRupees (money.mjs), applyCoupon (cart.mjs) 704 bytes, 424 gzip, 388 brotli out/report.chunk.mjs modules, dependencies first: money.mjs → report.mjs left out by tree shaking: GST_RATE (money.mjs), parseRupees (money.mjs) 530 bytes, 360 gzip, 294 brotli out/main.mjs prints the same as the source: yes Total with GST: ₹1,30,740.46 Laptop 2 × ₹54,999.00 Mouse 1 × ₹799.00
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bundle.mjs
app/src/main.mjs · app/src/main.mjs
import { total } from './cart.mjs';
import { formatRupees } from './money.mjs';
const items = [
{ name: 'Laptop', price: 5499900, qty: 2 },
{ name: 'Mouse', price: 79900, qty: 1 },
];
console.log(`Total with GST: ${formatRupees(total(items))}`);
const { printReport } = await import('./report.mjs');
printReport(items); app/src/cart.mjs · app/src/cart.mjs
import { GST_RATE } from './money.mjs';
export function subtotal(items) {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
export function total(items) {
const net = subtotal(items);
return net + Math.round(net * GST_RATE);
}
// Not used by the app either.
export function applyCoupon(paise, percent) {
return Math.round(paise * (1 - percent / 100));
} app/src/money.mjs · app/src/money.mjs
// Money helpers. Amounts are whole paise (1/100 of a rupee), so sums stay exact.
export const GST_RATE = 0.18;
export function formatRupees(paise) {
return `₹${(paise / 100).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
// The app never calls this one, so a bundler can leave it out.
export function parseRupees(text) {
const match = /^₹?([\d,]+)(?:\.(\d{1,2}))?$/.exec(text.trim());
if (!match) throw new Error(`not an amount: ${text}`);
return Number(match[1].replaceAll(',', '')) * 100 + Number((match[2] ?? '0').padEnd(2, '0'));
} app/src/report.mjs · app/src/report.mjs
import { formatRupees } from './money.mjs';
// The detailed report: most visitors never open it, so the app loads this module only when it is needed.
export function printReport(items) {
if (items.length === 0) throw new Error('the cart is empty: nothing to report');
for (const item of items) {
console.log(` ${item.name.padEnd(10)} ${String(item.qty).padStart(2)} × ${formatRupees(item.price).padStart(10)}`);
}
} - Resolve the module graph. Starting from the entry, it reads each imported file once and orders the modules so that every module comes after the ones it depends on: money, then cart, then main.
- Tree shaking. It keeps the statements that do something, such as the
console.logcall, plus every declaration they use, directly or through other declarations.parseRupeesandapplyCouponare never reached, so they are left out. - Join the modules. The kept code goes into one file in dependency order, with
importandexportremoved, because all the names now share one scope. That is called scope hoisting, and it is why two modules declaring the same top-level name must be renamed: the toy refuses, real bundlers rename one. - Split chunks. The dynamic
import()gets a file of its own, loaded only when that line runs.
The toy copies formatRupees into both of its files, because the report chunk needs it too. Real bundlers move
code that several chunks share into a shared chunk instead. They also parse the whole language into a syntax tree,
handle CommonJS packages, CSS and images, and know which code has no side effects. Vite serves your modules
to the browser as native ES modules while you develop and bundles them with Rolldown for production;
esbuild and Rollup are bundlers you can also run on their own, and webpack is the long-established
bundler that many existing projects build with.
A real build, read from its output
esbuild built the same app with code splitting, minification and source maps; the command is in the first lines of this script, which reads the metafile esbuild wrote, a JSON report of every input and output:
// Reads what esbuild wrote for the app in ../app/src: the metafile (meta.json) lists every output file, the source
// files inside it and how it is loaded; the .map files lead from the minified code back to the source. The files
// in dist/ were made with esbuild 0.28.2, run in the examples folder:
// esbuild app/src/main.mjs --bundle --splitting --format=esm --minify --sourcemap --target=es2022
// --metafile=esbuild-build/meta.json --outdir=esbuild-build/dist
import module from 'node:module';
import { readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
const meta = JSON.parse(readFileSync('meta.json', 'utf8'));
const local = (path) => path.replace(/^esbuild-build\//, '');
const js = Object.entries(meta.outputs).filter(([file]) => file.endsWith('.js'));
// Who loads each output: nobody (it is the entry point), a static import, or a dynamic import() that runs later.
const loadedBy = (file) => {
const by = js.filter(([, o]) => o.imports.some((i) => i.path === file));
if (!by.length) return 'nothing: the entry point';
return by.map(([f, o]) => `${local(f).replace('dist/', '')} (${o.imports.find((i) => i.path === file).kind === 'dynamic-import' ? 'import(), when it runs' : 'import'})`).join(', ');
};
console.log('output bytes gzip loaded by');
for (const [file, out] of js) {
const gz = gzipSync(readFileSync(local(file)), { level: 9 }).length;
console.log(`${local(file).padEnd(24)} ${String(out.bytes).padStart(5)} ${String(gz).padStart(5)} ${loadedBy(file)}`);
for (const [input, { bytesInOutput }] of Object.entries(out.inputs)) {
console.log(` ${input.padEnd(22)} ${String(meta.inputs[input].bytes).padStart(4)} bytes of source, ${bytesInOutput} in this file`);
}
}
// Tree shaking left parseRupees out: its error message is in the source and in no output.
const outputsText = js.map(([file]) => readFileSync(local(file), 'utf8')).join('\n');
console.log(`\n"not an amount" in app/src/money.mjs: yes; in the outputs: ${outputsText.includes('not an amount') ? 'yes' : 'no'}`);
// A crash inside the minified report chunk, without and then with source maps. Node.js reads a module's source map
// when it loads the module, so after switching source maps on, the chunk is loaded again under a new URL.
const reportFile = local(js.find(([, o]) => o.entryPoint === 'app/src/report.mjs')[0]);
const { printReport } = await import(`./${reportFile}`);
const topFrame = () => {
try {
printReport([]);
} catch (error) {
return error.stack.split('\n').find((line) => line.trim().startsWith('at '))?.trim();
}
};
console.log(`\nprintReport([]) throws; the top frame of the stack:`);
console.log(` without source maps: ${topFrame()}`);
module.setSourceMapsSupport(true);
const { printReport: mapped } = await import(`./${reportFile}?mapped`);
try {
mapped([]);
} catch (error) {
console.log(` with source maps: ${error.stack.split('\n').find((line) => line.trim().startsWith('at ')).trim()}`);
} Output
output bytes gzip loaded by dist/main.js 362 297 nothing: the entry point app/src/cart.mjs 385 bytes of source, 108 in this file app/src/main.mjs 326 bytes of source, 180 in this file dist/report-UP4CPKH4.js 310 265 main.js (import(), when it runs) app/src/report.mjs 429 bytes of source, 200 in this file dist/chunk-GXZWVBAF.js 172 168 main.js (import), report-UP4CPKH4.js (import) app/src/money.mjs 589 bytes of source, 113 in this file "not an amount" in app/src/money.mjs: yes; in the outputs: no printReport([]) throws; the top frame of the stack: without source maps: at n (dist/report-UP4CPKH4.js:1:77) with source maps: at n (app/src/report.mjs:5:33)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node read-build.mjs
The app after esbuild, as three files
Text description of the diagram
The diagram shows the three JavaScript files esbuild wrote for the app's four source modules, and the imports between them.
- `main.js` is the entry point: it holds `main.mjs` and `cart.mjs`, 362 bytes minified.
- `report-UP4CPKH4.js` holds `printReport` from `report.mjs`, 310 bytes. `main.js` loads it with `import()` only when that line runs (a dashed arrow), so the first download is smaller.
- `chunk-GXZWVBAF.js` holds `formatRupees` from `money.mjs`, 172 bytes. Both `main.js` and the report chunk import it, so esbuild put it in a shared chunk instead of copying it into both.
- `parseRupees` and `applyCoupon` appear in none of the three files: tree shaking left them out, because no code calls them.
- Three files, not four modules.
main.jsholdsmain.mjsandcart.mjs;formatRupees, which bothmain.jsand the report need, went into the sharedchunk-GXZWVBAF.js; and the report chunk loads throughimport(). The letters in the names are a hash of each file’s content, so a changed file gets a new name and a browser can keep the old one cached indefinitely. - Tree shaking is visible in the bytes.
money.mjshas 589 bytes of source and puts 113 into the output, and the error message ofparseRupeesappears in no output at all. - Minification renamed and folded. In
dist/main.js,formatRupeesis callednandGST_RATEhas become the number.18insidetotal, because a constant that never changes can be written where it is used. - gzip shrinks text further on the way. Servers compress JavaScript, so the number that matters for loading time is closer to the gzip column than to the bytes column; on files this small, compression saves less than on real bundles.
- Source maps lead back to the source. The minified report chunk crashes at line 1, column 77 of
dist/report-UP4CPKH4.js. With source maps switched on, Node.js reportsapp/src/report.mjs:5:33, thethrowin the file you wrote. The name staysn; the position is what the map translates. The format of.mapfiles is standardised as ECMA-426.
Vite’s production build does not write source maps unless build.sourcemap is set; 'hidden' writes them
without the comment that points browsers to them, which suits uploading them to an error tracker without
publishing your source.
Transpilers rewrite syntax
An engine that does not know a piece of syntax cannot run the file at all: it stops with a SyntaxError before the
first line runs, so no code inside the file can fix it. The fix has to happen before the browser sees the file. Here
is a short class written with syntax from ECMAScript 2020 and 2022, and what esbuild writes for it with
--target=es2019:
modern.mjs (the source) · transpile/modern.mjs
// Written with syntax from ECMAScript 2020 and 2022: private fields, optional chaining and ??.
class Wallet {
#balance = 0;
deposit(paise) {
this.#balance += paise;
return this;
}
get balance() {
return this.#balance;
}
}
const order = { customer: { name: 'Asha' }, coupon: null };
const wallet = new Wallet().deposit(25000).deposit(1250);
console.log(`${order.customer?.name} has ${wallet.balance} paise; coupon: ${order.coupon?.code ?? 'none'}`); Output
Asha has 26250 paise; coupon: none
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node modern.mjs
lowered.mjs (esbuild, es2019) · transpile/lowered.mjs
// What esbuild 0.28.2 wrote for modern.mjs with --target=es2019 (esbuild modern.mjs --target=es2019 --format=esm
// --outfile=lowered.mjs); only these two comment lines were added.
var __typeError = (msg) => {
throw TypeError(msg);
};
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
var _balance, _a, _b, _c;
class Wallet {
constructor() {
__privateAdd(this, _balance, 0);
}
deposit(paise) {
__privateSet(this, _balance, __privateGet(this, _balance) + paise);
return this;
}
get balance() {
return __privateGet(this, _balance);
}
}
_balance = new WeakMap();
const order = { customer: { name: "Asha" }, coupon: null };
const wallet = new Wallet().deposit(25e3).deposit(1250);
console.log(`${(_a = order.customer) == null ? void 0 : _a.name} has ${wallet.balance} paise; coupon: ${(_c = (_b = order.coupon) == null ? void 0 : _b.code) != null ? _c : "none"}`); Output
Asha has 26250 paise; coupon: none
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lowered.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Both print the same line. The private field became a WeakMap with helper functions, ?. became a
== null ? void 0 : test, and ?? became a != null test. Without its two comment lines, esbuild’s output is
1,268 bytes against the source’s 475, and every older target you add makes code like this bigger and slower for
everyone. esbuild’s documentation is explicit about the limits: target only rewrites syntax and never adds
polyfills for APIs, and a few constructs, among them top-level await, always pass through unchanged, whatever the
target. Babel, SWC, Oxc (whose transformer Vite 8 uses) and TypeScript’s own compiler are other transpilers.
Node.js 24 even contains a small one: it runs .ts files by replacing type annotations with spaces, so line and
column numbers stay the same and no source map is needed.
Polyfills add missing APIs
A missing API is different from missing syntax: Iterator.concat is just a property that does not exist yet, and
code can add it. A good polyfill checks for the feature first, installs itself only when it is absent, uses the same
property flags as the built-ins, and follows the specification’s behaviour:
// A polyfill adds a missing built-in at run time, and only where it is missing. Iterator.concat() is new in
// ECMAScript 2026: Node.js 24 does not have it, and the engine of the Run button does. This one follows the
// specification's main rules but not every detail; a polyfill library such as core-js follows it step by step.
if (typeof Iterator.concat !== 'function') {
Object.defineProperty(Iterator, 'concat', {
value: function concat(...items) {
for (const item of items) {
const isObject = item !== null && (typeof item === 'object' || typeof item === 'function');
if (!isObject || typeof item[Symbol.iterator] !== 'function') throw new TypeError('Iterator.concat() takes iterable objects');
}
return (function* () {
for (const item of items) yield* item;
})();
},
// The same flags as the built-in methods: writable and configurable, but not enumerable.
writable: true,
enumerable: false,
configurable: true,
});
console.log('Iterator.concat is missing here: the polyfill installed it');
} else {
console.log('Iterator.concat is built in here: the polyfill did nothing');
}
const morning = ['08:05 Pune', '09:40 Mysuru'];
const evening = new Set(['18:20 Kochi', '21:10 Jaipur']);
const departures = Iterator.concat(morning, evening.values());
console.log(departures.map((d) => d.toUpperCase()).toArray().join(' | '));
console.log('listed by Object.keys(Iterator)?', Object.keys(Iterator).includes('concat'));
try {
Iterator.concat('a string');
} catch (error) {
console.log(`a string is not accepted: ${error.name}`);
} Output
Iterator.concat is missing here: the polyfill installed it 08:05 PUNE | 09:40 MYSURU | 18:20 KOCHI | 21:10 JAIPUR listed by Object.keys(Iterator)? false a string is not accepted: TypeError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node iterator-concat.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Version note
The Run button runs this in QuickJS, a small JavaScript engine that already has Iterator.concat, so its first
line says the polyfill did nothing. Node.js 24 lacks it, so the recorded output shows the polyfill installing
itself. Everything after the first line is the same in both.
In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:
Iterator.concat is built in here: the polyfill did nothing 08:05 PUNE | 09:40 MYSURU | 18:20 KOCHI | 21:10 JAIPUR listed by Object.keys(Iterator)? false a string is not accepted: TypeError
Write polyfills yourself only for small functions like this one. For anything larger, use a maintained library such
as core-js; Babel’s preset-env can add its polyfills only for the features your code uses and your targets
lack. Serve polyfills from your own build, too: in 2024 a widely used polyfill domain changed owners, and the script
it served began redirecting some mobile visitors of the more than 100,000 sites that loaded it, as Sansec’s
researchers reported.
Choosing browser targets with Baseline
Baseline is a status that the W3C WebDX Community Group’s web-features project gives each web platform feature, computed from MDN’s browser-compat-data. It looks at the core browser set: Chrome and Firefox on desktop and Android, Edge, and Safari on macOS and iOS.
- Limited availability: some browser of the set has not shipped the feature yet.
- Newly available: the feature works in the latest version of all seven of them.
- Widely available: 30 months have gone by since then, a window the project chose to approximate how long browser updates take to reach most users.
Build tools accept targets in these terms. Vite 8’s default build.target is 'baseline-widely-available', which
Vite fixes for each major version as Chrome and Edge 111, Firefox 114 and Safari 16.4, and Browserslist accepts
queries such as baseline widely available and baseline 2024. This script reads an extract of web-features 3.42.0
and checks a checkout module against two sets of targets, Vite 8’s default and the current versions in
browser-compat-data 8.1.5:
baseline/baseline-check.mjs
// Which features of checkout.mjs work in a list of browsers, and what to do about the ones that do not? The support
// data is a small extract of web-features (features.json beside this file). The patterns that find each feature are
// deliberately simple; a linter rule does this properly in a real project.
import { readFileSync } from 'node:fs';
const data = JSON.parse(readFileSync('features.json', 'utf8'));
const code = readFileSync('checkout.mjs', 'utf8').replace(/^\s*\/\/.*$/gm, '');
// What each feature looks like in code, and whether it is syntax (a build tool must rewrite it) or an API (a
// polyfill can add it).
const FEATURES = {
'Optional chaining': ['syntax', /\?\.(?!\d)/],
'Nullish coalescing': ['syntax', /\?\?(?!=)/],
'Private class fields': ['syntax', /#[A-Za-z_]\w*/],
'Top-level await': ['syntax', /^(?!\s)(?:.*[\s=(])?await\s/m],
'using declarations': ['syntax', /^\s*using\s+\w+\s*=/m],
'Array toSorted()': ['API', /\.toSorted\(/],
'Object.groupBy()': ['API', /Object\.groupBy\(/],
'Promise.withResolvers()': ['API', /Promise\.withResolvers\(/],
'Set union()': ['API', /\.union\(/],
'Iterator helpers': ['API', /Iterator\.from\(/],
'Math.sumPrecise()': ['API', /Math\.sumPrecise\(/],
'Map getOrInsert()': ['API', /\.getOrInsert(?:Computed)?\(/],
Temporal: ['API', /\bTemporal\./],
'structuredClone()': ['API', /\bstructuredClone\(/],
};
const BROWSERS = { chrome: 'Chrome', edge: 'Edge', firefox: 'Firefox', safari: 'Safari', safari_ios: 'Safari on iOS' };
const TARGETS = {
"Vite 8's default targets": { chrome: '111', edge: '111', firefox: '114', safari: '16.4', safari_ios: '16.4' },
'current browsers': { chrome: '155', edge: '154', firefox: '157', safari: '27', safari_ios: '27' },
};
const atLeast = (have, need) => {
const [a, b] = [have.split('.').map(Number), need.split('.').map(Number)];
for (let i = 0; i < Math.max(a.length, b.length); i++) if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
return true;
};
const status = (f) => (f.baseline === 'high' ? `widely available since ${f.widelyAvailable}` : f.baseline === 'low' ? `newly available since ${f.newlyAvailable}` : 'limited availability');
// Top-level await is the one syntax feature here that no transpiler rewrites (esbuild passes it through unchanged).
const remedy = (name, kind) => (name === 'Top-level await' ? 'restructure the code' : kind === 'syntax' ? 'transpile it' : 'polyfill it');
const found = data.features.filter((f) => FEATURES[f.name][1].test(code));
console.log(`${data.source.split(' (')[0]}: ${found.length} of ${data.features.length} features found in checkout.mjs\n`);
const plan = Object.fromEntries(Object.keys(TARGETS).map((t) => [t, {}]));
for (const f of found) {
const [kind] = FEATURES[f.name];
console.log(`${f.name} (${kind}): Baseline ${status(f)}`);
for (const [target, versions] of Object.entries(TARGETS)) {
const missing = Object.keys(BROWSERS).filter((b) => f.support[b] === null || !atLeast(versions[b], f.support[b]));
const verdict = missing.length ? remedy(f.name, kind) : 'works';
(plan[target][verdict] ??= []).push(f.name);
console.log(` ${target}: ${missing.length ? `missing in ${missing.map((b) => BROWSERS[b]).join(', ')}: ${verdict}` : 'works'}`);
}
}
for (const [target, verdicts] of Object.entries(plan)) {
console.log(`\nFor ${target}:`);
for (const [verdict, names] of Object.entries(verdicts)) console.log(` ${verdict}: ${names.join(', ')}`);
} Output
web-features 3.42.0: 9 of 14 features found in checkout.mjs Optional chaining (syntax): Baseline widely available since 2023 Vite 8's default targets: works current browsers: works Nullish coalescing (syntax): Baseline widely available since 2023 Vite 8's default targets: works current browsers: works Private class fields (syntax): Baseline widely available since 2024 Vite 8's default targets: works current browsers: works Top-level await (syntax): Baseline newly available since 2026 Vite 8's default targets: missing in Safari, Safari on iOS: restructure the code current browsers: works using declarations (syntax): Baseline limited availability Vite 8's default targets: missing in Chrome, Edge, Firefox, Safari, Safari on iOS: transpile it current browsers: missing in Safari, Safari on iOS: transpile it Array toSorted() (API): Baseline widely available since 2026 Vite 8's default targets: missing in Firefox: polyfill it current browsers: works Object.groupBy() (API): Baseline widely available since 2026 Vite 8's default targets: missing in Chrome, Edge, Firefox, Safari, Safari on iOS: polyfill it current browsers: works Set union() (API): Baseline newly available since 2024 Vite 8's default targets: missing in Chrome, Edge, Firefox, Safari, Safari on iOS: polyfill it current browsers: works Math.sumPrecise() (API): Baseline newly available since 2026 Vite 8's default targets: missing in Chrome, Edge, Firefox, Safari, Safari on iOS: polyfill it current browsers: works For Vite 8's default targets: works: Optional chaining, Nullish coalescing, Private class fields restructure the code: Top-level await transpile it: using declarations polyfill it: Array toSorted(), Object.groupBy(), Set union(), Math.sumPrecise() For current browsers: works: Optional chaining, Nullish coalescing, Private class fields, Top-level await, Array toSorted(), Object.groupBy(), Set union(), Math.sumPrecise() transpile it: using declarations
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node baseline-check.mjs
baseline/checkout.mjs
// The module baseline-check.mjs reads (it is scanned, not run): a checkout page's script written with recent
// JavaScript. On Node.js 24 it would stop at Math.sumPrecise, which Node.js 24 does not have.
import { loadCart, openPaymentSession } from './api.mjs';
class Checkout {
#cart;
constructor(cart) {
this.#cart = cart;
}
linesBySeller() {
return Object.groupBy(this.#cart.lines.toSorted((a, b) => a.price - b.price), (line) => line.seller ?? 'MySmartCoPilot Store');
}
totalPaise() {
return Math.sumPrecise(this.#cart.lines.map((line) => line.price * line.qty));
}
}
const cart = await loadCart();
const sellers = new Set(cart.lines.map((line) => line.seller)).union(new Set(cart.savedSellers));
const checkout = new Checkout(cart);
console.log(checkout.totalPaise(), sellers.size, cart.coupon?.code ?? 'no coupon');
{
using session = await openPaymentSession(checkout.totalPaise());
session.confirm();
} baseline/features.json
{
"source": "web-features 3.42.0 (npm), data.json, licensed Apache-2.0 by the W3C WebDX Community Group and the web-features contributors. A few fields of 14 features: the Baseline status, the years it became newly and widely available, and the first version of each browser with full support (null: none yet).",
"features": [
{
"name": "Optional chaining",
"id": "object-object",
"compatKey": "javascript.operators.optional_chaining",
"baseline": "high",
"newlyAvailable": 2020,
"widelyAvailable": 2023,
"support": {
"chrome": "80",
"edge": "80",
"firefox": "74",
"safari": "13.1",
"safari_ios": "13.4"
}
},
{
"name": "Nullish coalescing",
"id": "nullish-coalescing",
"baseline": "high",
"newlyAvailable": 2020,
"widelyAvailable": 2023,
"support": {
"chrome": "85",
"edge": "85",
"firefox": "79",
"safari": "14",
"safari_ios": "14"
}
},
{
"name": "Private class fields",
"id": "class-syntax",
"compatKey": "javascript.classes.private_class_fields",
"baseline": "high",
"newlyAvailable": 2021,
"widelyAvailable": 2024,
"support": {
"chrome": "74",
"edge": "79",
"firefox": "90",
"safari": "14.1",
"safari_ios": "14.5"
}
},
{
"name": "Top-level await",
"id": "top-level-await",
"baseline": "low",
"newlyAvailable": 2026,
"widelyAvailable": null,
"support": {
"chrome": "89",
"edge": "89",
"firefox": "89",
"safari": "27",
"safari_ios": "27"
}
},
{
"name": "using declarations",
"id": "explicit-resource-management",
"compatKey": "javascript.statements.using",
"baseline": false,
"newlyAvailable": null,
"widelyAvailable": null,
"support": {
"chrome": "134",
"edge": "134",
"firefox": "141",
"safari": null,
"safari_ios": null
}
},
{
"name": "Array toSorted()",
"id": "array-by-copy",
"compatKey": "javascript.builtins.Array.toSorted",
"baseline": "high",
"newlyAvailable": 2023,
"widelyAvailable": 2026,
"support": {
"chrome": "110",
"edge": "110",
"firefox": "115",
"safari": "16",
"safari_ios": "16"
}
},
{
"name": "Object.groupBy()",
"id": "array-group",
"compatKey": "javascript.builtins.Object.groupBy",
"baseline": "high",
"newlyAvailable": 2024,
"widelyAvailable": 2026,
"support": {
"chrome": "117",
"edge": "117",
"firefox": "119",
"safari": "17.4",
"safari_ios": "17.4"
}
},
{
"name": "Promise.withResolvers()",
"id": "promise-withresolvers",
"baseline": "high",
"newlyAvailable": 2024,
"widelyAvailable": 2026,
"support": {
"chrome": "119",
"edge": "119",
"firefox": "121",
"safari": "17.4",
"safari_ios": "17.4"
}
},
{
"name": "Set union()",
"id": "set-methods",
"compatKey": "javascript.builtins.Set.union",
"baseline": "low",
"newlyAvailable": 2024,
"widelyAvailable": null,
"support": {
"chrome": "122",
"edge": "122",
"firefox": "127",
"safari": "17",
"safari_ios": "17"
}
},
{
"name": "Iterator helpers",
"id": "iterator-methods",
"compatKey": "javascript.builtins.Iterator.map",
"baseline": "low",
"newlyAvailable": 2025,
"widelyAvailable": null,
"support": {
"chrome": "122",
"edge": "122",
"firefox": "131",
"safari": "18.4",
"safari_ios": "18.4"
}
},
{
"name": "Math.sumPrecise()",
"id": "math-sum-precise",
"baseline": "low",
"newlyAvailable": 2026,
"widelyAvailable": null,
"support": {
"chrome": "147",
"edge": "147",
"firefox": "137",
"safari": "26.2",
"safari_ios": "26.2"
}
},
{
"name": "Map getOrInsert()",
"id": "getorinsert",
"compatKey": "javascript.builtins.Map.getOrInsert",
"baseline": "low",
"newlyAvailable": 2026,
"widelyAvailable": null,
"support": {
"chrome": "145",
"edge": "145",
"firefox": "144",
"safari": "26.2",
"safari_ios": "26.2"
}
},
{
"name": "Temporal",
"id": "temporal",
"compatKey": "javascript.builtins.Temporal",
"baseline": false,
"newlyAvailable": null,
"widelyAvailable": null,
"support": {
"chrome": "144",
"edge": "144",
"firefox": "139",
"safari": null,
"safari_ios": null
}
},
{
"name": "structuredClone()",
"id": "structured-clone",
"baseline": "high",
"newlyAvailable": 2022,
"widelyAvailable": 2024,
"support": {
"chrome": "98",
"edge": "98",
"firefox": "94",
"safari": "15.4",
"safari_ios": "15.4"
}
}
]
} Three results deserve a second look. Array.prototype.toSorted is widely available, yet Vite’s fixed default
target lacks it in Firefox, which added it in Firefox 115, one version above the target: a status is about
browsers today, while a target is a list of versions. Top-level await became Baseline only when Safari 27 fixed
its last gap: browser-compat-data marks Safari 15 to 26.6 as a partial implementation, because those versions fail
when several modules import a module that uses it at the same time. And using declarations have limited
availability even in current browsers, because Safari has not shipped them, so they still need a transpiler.
Choose targets from your own visitors when you can: your analytics show which browsers they use, and an Indian audience on budget Android phones and an office on managed laptops need different lists. Without that data, Baseline widely available is a sensible default, and every older browser you add should earn its cost in bytes.
Reading a build report
Whichever bundler you use, ask its report the same questions:
- Which file is largest, and is it needed at start? Anything that only some pages or actions need can move
behind an
import(). - What is inside each chunk? A metafile, esbuild’s
--analyzeor a bundle visualiser plugin shows the bytes each source file contributes; an unexpected library, or the same library twice in two versions, stands out. - What was left out? An input with no bytes in any output is code you could delete.
- Did anything defeat tree shaking? Tree shaking works on
importandexportstatements, so esbuild’s documentation says it does not work with CommonJS modules, and code that might have side effects when a module loads is kept; the lesson on module design shows how to avoid that.
Exercise
Exercise · Medium · JavaScript
Read an esbuild metafile and summarise the build
summary.mjs exports summarize(meta). It receives an esbuild metafile, the object that --metafile writes and that the lesson's read-build.mjs reads: meta.inputs maps each source file to its bytes and imports, and meta.outputs maps each output file to its bytes, its inputs (each with bytesInOutput) and its imports (each with a path and a kind such as import-statement or dynamic-import). Return an object with three answers:
largest: the path of the biggest JavaScript output bybytes. Source maps (paths ending in.map) do not count.lazy: the paths of the outputs that load only throughimport(): at least one output imports them with the kinddynamic-import, and none imports them with any other kind. Sorted.unusedInputs: the paths of the inputs that put no code into any output: they appear in no output'sinputs, or only withbytesInOutput: 0. Sorted.
Run the sample tests when you are done.
Starter code · summary.mjs
// Summarises an esbuild metafile (the exercise of lesson javascript/build-tools-and-bundlers).
export function summarize(meta) {
const outputs = Object.entries(meta.outputs);
// Write the three answers of the prompt here.
return { largest: null, lazy: [], unusedInputs: [] };
} The sample tests · summary.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { summarize } from './summary.mjs';
// A metafile of a small shop: an entry, a shared chunk, a checkout chunk loaded with import() and an admin chunk
// that one output imports statically and another dynamically. coupons.mjs and legacy.mjs ended up in no output.
const META = {
inputs: {
'src/main.mjs': { bytes: 900, imports: [] },
'src/cart.mjs': { bytes: 1200, imports: [] },
'src/money.mjs': { bytes: 700, imports: [] },
'src/checkout.mjs': { bytes: 5200, imports: [] },
'src/admin.mjs': { bytes: 2100, imports: [] },
'src/coupons.mjs': { bytes: 640, imports: [] },
'src/legacy.mjs': { bytes: 300, imports: [] },
},
outputs: {
'dist/main.js': {
bytes: 2400,
imports: [
{ path: 'dist/chunk-MONEY.js', kind: 'import-statement' },
{ path: 'dist/checkout-C1.js', kind: 'dynamic-import' },
{ path: 'dist/admin-A1.js', kind: 'dynamic-import' },
],
inputs: { 'src/main.mjs': { bytesInOutput: 610 }, 'src/cart.mjs': { bytesInOutput: 980 }, 'src/legacy.mjs': { bytesInOutput: 0 } },
},
'dist/main.js.map': { bytes: 9800, imports: [], inputs: {} },
'dist/chunk-MONEY.js': { bytes: 520, imports: [], inputs: { 'src/money.mjs': { bytesInOutput: 470 } } },
'dist/checkout-C1.js': {
bytes: 4100,
imports: [{ path: 'dist/chunk-MONEY.js', kind: 'import-statement' }, { path: 'dist/admin-A1.js', kind: 'import-statement' }],
inputs: { 'src/checkout.mjs': { bytesInOutput: 3900 } },
},
'dist/admin-A1.js': { bytes: 1700, imports: [], inputs: { 'src/admin.mjs': { bytesInOutput: 1650 } } },
},
};
test('largest is the biggest JavaScript output, not a source map', () => {
assert.equal(summarize(META).largest, 'dist/checkout-C1.js');
});
test('lazy lists only the outputs loaded through import() alone', () => {
assert.deepEqual(summarize(META).lazy, ['dist/checkout-C1.js']);
});
test('unusedInputs lists inputs with no bytes in any output, sorted', () => {
assert.deepEqual(summarize(META).unusedInputs, ['src/coupons.mjs', 'src/legacy.mjs']);
});
test('a build with one output and every input used', () => {
const meta = {
inputs: { 'a.mjs': { bytes: 10, imports: [] }, 'b.mjs': { bytes: 20, imports: [] } },
outputs: { 'out.js': { bytes: 25, imports: [], inputs: { 'a.mjs': { bytesInOutput: 8 }, 'b.mjs': { bytesInOutput: 15 } } } },
};
assert.deepEqual(summarize(meta), { largest: 'out.js', lazy: [], unusedInputs: [] });
}); A hint
Collect two lists first: every import of every output (Object.values(meta.outputs).flatMap((o) => o.imports)) and every input path that has at least one byte in some output. lazy keeps the outputs whose imports are all of the kind dynamic-import; unusedInputs is Object.keys(meta.inputs) without the second list.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
8 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between a polyfill and a transpiler?
A transpiler rewrites source code before it ships: it turns syntax that older engines cannot parse, such as
optional chaining or private class fields, into equivalent older syntax, so the file never contains the new syntax.
A polyfill is code that runs in the browser and adds a missing built-in API, such as Set.prototype.union or
Iterator.concat, after checking that it is really missing. The two solve different problems: syntax can only be
transpiled, because an engine that cannot parse a file runs none of it, and an API can only be polyfilled, because
a transpiler that sees a call to union() cannot know whether the object will have that method. Many projects need
both, configured from the same list of browser targets.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
After a refactor that only moved imports around, the gzipped JavaScript the home page loads grew from 180 KB to 410 KB. Nothing was added to package.json. How do you find where the extra bytes come from, fix it, and stop the next regression before it ships?
-
Mid to senior
Field staff use iPads that stay on Safari 15.6. After you upgraded the bundler, the app shows a blank page on those iPads and nowhere else. Walk through how you find the cause and what you change in the build.
Key takeaways
- A bundler resolves the module graph from an entry, leaves out unused declarations, joins modules into few files,
splits
import()targets and shared code into chunks, and names files by a hash of their content. - A transpiler rewrites new syntax for old engines and makes the code bigger; it never adds missing APIs, and a few
constructs, such as top-level
await, cannot be rewritten at all. - A polyfill adds a missing API at run time after testing for it; take larger ones from a maintained library and serve them from your own build.
- Newly available means the latest version of each core browser has a feature; widely available means 30 months have passed since that happened. A tool’s target is a list of versions, so check features against the list, not only against the status.
- Minified output plus source maps lets you debug the code you wrote; publish the maps only where you mean to.
- Read the build report for the largest file, what each chunk contains, duplicates and inputs that contribute nothing.
References
- Vite Guide: Getting started (Vite)
- Vite: Build options (build.target, build.minify, build.sourcemap) (Vite)
- esbuild API: target, splitting, tree shaking, metafile, minify (esbuild)
- esbuild: Content types (which syntax is transformed) (esbuild)
- Baseline (the definition, web-features documentation) (W3C WebDX Community Group)
- web-features (the data and its npm package) (W3C WebDX Community Group)
- MDN browser-compat-data (MDN Web Docs)
- Browserslist: queries by Baseline (Browserslist)
- ECMA-426: Source map format specification (Ecma International (TC39-TG4))
- Node.js v24 documentation: module.setSourceMapsSupport() and source map support (OpenJS Foundation (Node.js))
- Node.js v24 documentation: Modules: TypeScript (type stripping) (OpenJS Foundation (Node.js))
- TC39 finished proposals (Iterator Sequencing in ECMAScript 2026) (Ecma International (TC39))
- ECMAScript 2026 Language Specification: Iterator.concat (Ecma International)
- webpack: Concepts (webpack)
- Rollup: Introduction (Rollup)
- Rolldown: Introduction (Rolldown)
- Babel: What is Babel? (Babel)
- Babel: @babel/preset-env (useBuiltIns and core-js) (Babel)
- SWC: Getting started (SWC)
- Oxc: Transformer (Oxc)
- ECMAScript 2026 Language Specification: ECMAScript standard built-in objects (property attributes) (Ecma International)
- core-js (a modular standard library of polyfills) (core-js)
- Polyfill supply chain attack hits 100K+ sites (Sansec)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress