Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 24 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 24 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

A bundler in about a hundred lines, run on a four-module app

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)}`);
  }
}
  1. 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.
  2. Tree shaking. It keeps the statements that do something, such as the console.log call, plus every declaration they use, directly or through other declarations. parseRupees and applyCoupon are never reached, so they are left out.
  3. Join the modules. The kept code goes into one file in dependency order, with import and export removed, 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.
  4. 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:

esbuild's three files for the app, and a crash with and without source maps JavaScript · esbuild-build/read-build.mjs
// 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

esbuild split the app into main.js, a report chunk that main.js loads with import(), and a shared chunk with formatRupees.main.js (entry)main.mjs + cart.mjs362 bytesreport-UP4CPKH4.jsprintReport from report.mjs310 byteschunk-GXZWVBAF.jsformatRupees from money.mjs172 bytesimport(), when it runsimport, at startimport

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.js holds main.mjs and cart.mjs; formatRupees, which both main.js and the report need, went into the shared chunk-GXZWVBAF.js; and the report chunk loads through import(). 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.mjs has 589 bytes of source and puts 113 into the output, and the error message of parseRupees appears in no output at all.
  • Minification renamed and folded. In dist/main.js, formatRupees is called n and GST_RATE has become the number .18 inside total, 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 reports app/src/report.mjs:5:33, the throw in the file you wrote. The name stays n; the position is what the map translates. The format of .map files 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:

The same program before and after transpiling

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

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 for Iterator.concat(), from ECMAScript 2026 JavaScript · polyfill/iterator-concat.mjs
// 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

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:

Which features of a checkout module each set of targets lacks

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 --analyze or 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 import and export statements, 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.
JavaScript Minifier Minify a file of your own and compare its size before and after, with a source map that leads back to the original. Source Map Stack Trace Decoder (Unminify JavaScript Errors) Paste a stack trace from a minified bundle and its .map file to find the file, line and function it points to. Gzip & Deflate Compressor / Decompressor Compress a built file to see the size that actually travels over the network.

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 by bytes. Source maps (paths ending in .map) do not count.
  • lazy: the paths of the outputs that load only through import(): at least one output imports them with the kind dynamic-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's inputs, or only with bytesInOutput: 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.

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.

  1. Question 1 of 8 Your targets include a browser without Set.prototype.union, and your code calls a.union(b). Which tool makes that call work there?

    Choose one answer.

    Show the answer to question 1

    Answer: A polyfill that adds union() to Set.prototype when it is missing

    union is an API, a method that may or may not exist at run time, so code can add it. A transpiler rewrites syntax only; esbuild's documentation says its target never adds polyfills for APIs.

  2. Question 2 of 8 Why can no polyfill make a using declaration work in an engine that does not support it?

    Choose one answer.

    Show the answer to question 2

    Answer: The engine cannot parse the file, so it runs none of its code, the polyfill included

    New syntax makes the whole file a SyntaxError before its first line runs. Only rewriting the source before it ships, which is what a transpiler does, helps.

  3. Question 3 of 8 What does it mean that a feature is Baseline "widely available"?

    Choose one answer.

    Show the answer to question 3

    Answer: Every core browser has supported it for at least 30 months

    Supported in the current release of every core browser is "newly available"; 30 months later the feature becomes "widely available". The edition of the standard says nothing about support.

  4. Question 4 of 8 Array.prototype.toSorted is Baseline widely available, yet the lesson's check reports it missing for Vite 8's default targets. Why?

    Choose one answer.

    Show the answer to question 4

    Answer: Firefox added it in version 115, and the fixed default target is Firefox 114

    A status describes browsers today; a target is a fixed list of versions. Vite fixes its default list for each major version, and Firefox 114 is one release older than the first Firefox with toSorted.

  5. Question 5 of 8 Your targets include Safari 27. According to the lesson's features.json, which of these features needs a polyfill there, rather than a transpiler or nothing at all?

    Choose one answer.

    Show the answer to question 5

    Answer: Temporal

    Temporal is an API that no Safari version supports in that data, so code that uses it needs a polyfill there. using declarations are missing in Safari too, but they are syntax, so only a transpiler helps. Top-level await is fully supported from Safari 27, and Math.sumPrecise() from Safari 26.2.

  6. Question 6 of 8 In the lesson's esbuild build, which file does the program download only when the line with import() runs?

    Choose one answer.

    Show the answer to question 6

    Answer: report-UP4CPKH4.js

    report.mjs was loaded with a dynamic import(), so esbuild gave it a file of its own. The shared chunk holds formatRupees, which main.js imports statically, so it loads at start.

  7. Question 7 of 8 How did the lesson show that esbuild left parseRupees out, although its names were minified?

    Choose one answer.

    Show the answer to question 7

    Answer: Its error message, a string, appears in the source and in no output file

    Minification renames functions, so searching for a name proves little. A string literal is kept as it is, so its absence from every output shows the whole function was dropped.

  8. Question 8 of 8 What does a source map let a tool do?

    Choose one answer.

    Show the answer to question 8

    Answer: Translate a line and column of the generated file into a file, line and column of the source

    A .map file (ECMA-426) maps positions in the output back to positions in the sources, which is how the lesson's crash at dist/report-UP4CPKH4.js:1:77 became app/src/report.mjs:5:33.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.