A bundler is often described as a program that joins many JavaScript files into one. That description fit the earliest script concatenators, but it misses nearly everything that makes a modern bundler useful. Given one entry module, today’s tool must understand syntax from several languages, reproduce the runtime’s module-resolution rules, construct a dependency graph, remove code without changing observable behavior, split what remains into cacheable chunks, and coordinate JavaScript with CSS, images, workers, and source maps. In development it must repeat part of that work after every edit, usually before a developer can move a hand back from the keyboard.

The useful mental model is not “a clever file joiner” but an incremental compiler over a module graph. Its output is determined as much by graph structure and package metadata as by source text. Once that model is clear, options such as sideEffects, dynamic import(), content hashes, and hot module replacement stop looking like unrelated features.

The pipeline and its intermediate products

A production build usually moves through stages like these. Real implementations overlap and parallelize them, but the logical dependencies remain:

flowchart LR E[Entry modules] --> R[Resolve specifiers] R --> L[Load source] L --> P[Parse to AST] P --> T[Transform syntax] T --> G[Build and analyze graph] G --> S[Tree-shake symbols] S --> C[Plan chunks] C --> M[Render JS and CSS] M --> H[Hash and write assets] P --> SM[Source-map mappings] T --> SM M --> SM SM --> H

Each stage should produce structured information rather than forcing later stages to rediscover facts from strings.

Stage Important product Typical invalidation cause
Resolve and load Canonical module ID and source bytes File creation, alias or package metadata change
Parse AST, imports, exports, scopes Source content or parser-option change
Transform Target-compatible AST/code and map Source, target, plugin or define change
Graph analysis Reachability, symbol uses, side effects Import/export edge or package metadata change
Chunk planning Module-to-chunk assignment Entry, dynamic edge or sharing threshold change
Render and hash Files, references and final names Any rendered byte or naming-option change

Plugins commonly hook into resolve, load, transform, and output generation. That is powerful because a virtual module, a CSS file, or an SVG can enter the same graph as JavaScript. It is also why plugin ordering and cache keys matter: a plugin that reads an environment variable or an extra file must declare that dependency, or an incremental rebuild can reuse stale work.

Parsing is where text becomes meaning

Scanning for text that looks like import is not enough. The word can appear in a comment or string; an import specifier can contain escapes; TypeScript adds syntax that browsers cannot execute; JSX uses angle brackets for something other than comparison. A parser resolves these ambiguities and produces an abstract syntax tree (AST) whose nodes represent declarations, calls, literals, imports, and lexical scopes.

For example, these imports have very different consequences:

ts
import type { User } from './model.js';
import { format } from './format.js';
const locale = await import(`./locales/${language}.js`);

The type-only import can disappear after TypeScript erasure. The static import is an exact build-time edge. The template dynamic import is a runtime-dependent set of possible edges; a bundler must reject it, preserve it for the host runtime, or turn a statically enumerable directory into a context module. Pretending it names one ordinary file would be incorrect.

Transforms should operate on AST nodes while retaining source locations. TypeScript type removal, JSX lowering, optional-chaining lowering, compile-time constants, and minification may all rewrite the tree. Every rewrite can contribute a source-map segment connecting generated positions back to the original file. If a plugin prints code and discards locations, mappings after that point can only be approximate.

Note

Bundlers generally erase TypeScript syntax; they do not replace a full type checker. A fast build may succeed while tsc --noEmit still reports a type error, so type checking remains a separate CI or editor responsibility.

Parsing also enables scope-aware decisions. Renaming a local variable must not rename an unrelated property. Replacing process.env.MODE must not affect a locally shadowed process. Removing an export requires knowing which binding it references and whether its initializer has observable effects. Those are syntax and scope questions, not regular-expression problems.

Resolution builds the module graph

An import contains a specifier, not necessarily a path. ./math.js, react, #config, and pkg/feature follow different rules. Resolution considers the importer, extension rules, aliases, package exports and conditions such as browser, symlinks, and sometimes TypeScript path mappings. Its result should be a canonical module ID so that two spellings of the same file do not create duplicate module instances.

The following deliberately small TypeScript model handles relative modules only. It still shows four important properties: resolution happens relative to the importer, extension candidates are explicit, a module is marked before recursion so cycles terminate, and static and dynamic edges remain distinct. analyzeImports is assumed to walk an AST rather than scan source text.

ts
import { posix } from 'node:path';

type ImportFacts = {
  staticSpecifiers: string[];
  dynamicSpecifiers: string[];
};

type ModuleNode = {
  id: string;
  source: string;
  staticDeps: string[];
  dynamicDeps: string[];
  transformed: string;
};

type AnalyzeImports = (source: string, id: string) => ImportFacts;
type TransformModule = (source: string, id: string) => string;

function resolveRelative(
  specifier: string,
  importer: string,
  files: ReadonlyMap<string, string>,
): string {
  if (!specifier.startsWith('.')) {
    throw new Error(`This example supports relative imports only: ${specifier}`);
  }

  const base = posix.normalize(posix.join(posix.dirname(importer), specifier));
  const candidates = posix.extname(base)
    ? [base]
    : [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}/index.ts`];
  const resolved = candidates.find((candidate) => files.has(candidate));

  if (!resolved) throw new Error(`Cannot resolve ${specifier} from ${importer}`);
  return resolved;
}

function buildGraph(
  entry: string,
  files: ReadonlyMap<string, string>,
  analyzeImports: AnalyzeImports,
  transformModule: TransformModule,
): Map<string, ModuleNode> {
  const graph = new Map<string, ModuleNode>();

  function visit(id: string): void {
    if (graph.has(id)) return;
    const source = files.get(id);
    if (source === undefined) throw new Error(`Missing module: ${id}`);

    const facts = analyzeImports(source, id);
    const staticDeps = facts.staticSpecifiers.map((value) =>
      resolveRelative(value, id, files),
    );
    const dynamicDeps = facts.dynamicSpecifiers.map((value) =>
      resolveRelative(value, id, files),
    );

    graph.set(id, {
      id,
      source,
      staticDeps,
      dynamicDeps,
      transformed: transformModule(source, id),
    });
    for (const dependency of [...staticDeps, ...dynamicDeps]) visit(dependency);
  }

  visit(entry);
  return graph;
}

Marking the node before visiting dependencies is essential for legal cycles such as a.ts -> b.ts -> a.ts. It prevents infinite traversal, though rendering must still preserve ECMAScript’s live bindings and initialization order. A bundler cannot safely compile imports into arbitrary copied values without changing cycle behavior.

Production resolvers cache both successes and failures, but invalidation is subtle. If ./panel initially fails and panel.ts is then created, the negative cache must be cleared. Package exports also prevent the old habit of probing any file under node_modules: only declared subpaths may be public.

Tree-shaking is graph analysis plus side-effect analysis

Tree-shaking starts from observable roots: entry execution, used exports, and explicitly retained side effects. It propagates symbol liveness through import/export links, then excludes declarations that are unreachable and safe to remove. ES modules make this practical because static imports and exports expose structure before execution.

Reachability alone is insufficient. Consider export const registry = registerPlugin(). Even when nobody imports registry, deleting the call may change the program. By contrast, an unused function declaration is normally removable because declaring it does not execute its body.

Construct Usually removable when unused? Why
function helper() { ... } Yes Declaration has no immediate effect
const answer = 6 * 7 Yes Initializer is statically pure
const item = factory() Not without proof or annotation Call may mutate state or throw
import './polyfill.js' No The import exists specifically for execution
Getter read object.value Not safely by default Getter may execute user code

Package-level sideEffects: false is a promise from the package author that modules have no top-level effects requiring retention. A list such as sideEffects: ["*.css", "./register.js"] makes exceptions. An incorrect promise can produce a smaller bundle that is observably broken.

Warning

Tree-shaking is not “delete every unused line.” It is a conservative proof. Dynamic property access, eval, CommonJS mutation, getters, decorators, and unknown calls reduce what a bundler can prove safely.

Transforms influence that proof. Replacing if (import.meta.env.PROD) with if (true) allows dead-branch elimination. Conversely, lowering a class field into a helper call may make a previously obvious operation look effectful unless the optimizer understands the helper. Mature bundlers coordinate lowering and optimization instead of treating them as independent text filters.

Code splitting, rendering, and content hashes

A static import must be available when its importer executes, so it normally joins the importer’s chunk or a synchronously loaded shared chunk. A dynamic import('./chart.js') is an asynchronous boundary: the chart and its static dependencies can move into a lazy chunk. If several boundaries use the same large dependency, a planner may extract a shared chunk to avoid duplication, balanced against the extra network request.

This continuation of the simplified model computes static closure, keeps modules already in the entry chunk out of lazy chunks, renders deterministic module order, and derives a filename from emitted bytes. A real renderer also rewrites module references to chunk filenames and supplies a runtime loader.

ts
import { createHash } from 'node:crypto';

type Chunk = { name: string; modules: Set<string> };

function staticClosure(roots: Iterable<string>, graph: ReadonlyMap<string, ModuleNode>) {
  const seen = new Set<string>();
  const visit = (id: string): void => {
    if (seen.has(id)) return;
    seen.add(id);
    const node = graph.get(id);
    if (!node) throw new Error(`Unknown module: ${id}`);
    for (const dependency of node.staticDeps) visit(dependency);
  };
  for (const root of roots) visit(root);
  return seen;
}

function planChunks(entry: string, graph: ReadonlyMap<string, ModuleNode>): Chunk[] {
  const mainModules = staticClosure([entry], graph);
  const chunks: Chunk[] = [{ name: 'main', modules: mainModules }];
  const dynamicRoots = new Set(
    [...graph.values()].flatMap((module) => module.dynamicDeps),
  );

  for (const root of dynamicRoots) {
    const modules = staticClosure([root], graph);
    for (const id of mainModules) modules.delete(id);
    chunks.push({ name: posix.basename(root, posix.extname(root)), modules });
  }
  return chunks;
}

function emitChunk(chunk: Chunk, graph: ReadonlyMap<string, ModuleNode>) {
  const code = [...chunk.modules]
    .sort()
    .map((id) => `define(${JSON.stringify(id)}, ${graph.get(id)!.transformed});`)
    .join('\n');
  const hash = createHash('sha256').update(code).digest('hex').slice(0, 10);
  return { fileName: `${chunk.name}.${hash}.js`, code };
}

This educational planner may duplicate a module shared only by two lazy chunks. Production planners detect such overlap, enforce minimum sizes, honor manual chunk rules, and avoid creating a forest of tiny files. The best split is a cost model, not simply the maximum number of chunks.

Content hashing supports long-lived HTTP caching: chart.a81c...js can be cached as immutable because changed bytes receive a new URL. Deterministic ordering is crucial; if traversal order randomly changes output, hashes churn without semantic changes. Hashing is also recursive because a parent chunk contains the hashed filename of a child. Bundlers solve that with staged identifiers, placeholder replacement, or a stable hashing graph.

CSS, assets, source maps, and two different build loops

Non-JavaScript imports become typed graph nodes. A CSS loader parses @import and url(), rewrites asset references, may scope CSS Module class names, and emits either style-injection code or extracted CSS. An image loader can inline a small file as a data URL and emit a larger one with a hashed name. Web workers and WebAssembly often create their own output boundaries. In every case the bundler must track dependencies so an edited font or image invalidates its consumers.

Development and production optimize for different outcomes:

Concern Development server Production build
Primary goal Low edit-to-feedback latency Small, stable, cacheable output
Module serving Often native ESM or lightly transformed modules Planned and linked chunks
Optimization Minimal or cached Tree-shaking, minification, extraction
Updates In-memory graph invalidation and HMR Full deterministic artifact set
Source maps Fast, sometimes less compact Accurate external or hidden maps
Filenames Stable human-readable URLs Content-hashed immutable URLs

On a file change, a dev server follows reverse graph edges to find importers. With hot module replacement (HMR), it searches upward for a module that accepts the update, sends the changed module and metadata to the browser, disposes the old instance, and reevaluates the accepted boundary. If no boundary accepts it, the server reloads the page. HMR therefore needs application or framework cooperation; preserving component state across replacement is not something a generic graph traversal can infer.

Source maps form another chain. A TypeScript transform maps lowered JavaScript to TypeScript; a minifier maps minified code to its input; chunk rendering offsets each module inside a file. The bundler composes these mappings so a production stack frame can point back to the original source. Maps can contain source text and reveal proprietary code, so production deployments often upload them privately to an error service instead of serving them publicly.

When builds become slow, measure before changing options. Separate cold start from incremental rebuild, and inspect resolution, parsing, plugin transforms, optimization, source-map generation, compression, and disk output independently. Frequent causes include a transform running on node_modules, a plugin doing serial network I/O, enormous generated modules, broad glob imports, expensive minification, and caches invalidated by unstable configuration objects.

For a broken bundle, preserve the graph and intermediate artifacts. Ask which resolver condition selected a file, why a module was retained, which plugin last transformed it, and which chunk owns it. Metafiles, bundle visualizers, resolver traces, and sourcesContent are more useful than staring at minified output. Reproduce production defines and conditions locally: many “minifier bugs” are actually environment replacement or side-effect metadata differences.

Takeaways

  • A modern bundler is an incremental compiler whose central data structure is a module graph.
  • ASTs provide the syntax, scope, import/export, and source-location facts required for correct transforms.
  • Resolution must follow explicit runtime and package rules and return canonical, cache-aware module identities.
  • Tree-shaking preserves reachable behavior, not merely referenced text; side-effect claims are part of correctness.
  • Dynamic imports create possible chunk boundaries, while deterministic rendering and content hashes enable durable caching.
  • CSS, assets, workers, HMR, and source maps belong to the same dependency and invalidation model.
  • Performance work starts with stage-level timings and graph diagnostics, because a fast incorrect cache is still incorrect.

Once these pieces are viewed as one pipeline, bundler behavior becomes much easier to predict. A surprising output file is usually explainable by a resolution rule, a graph edge, an effect the optimizer could not disprove, or a chunking constraint. The tool may be sophisticated, but its core obligations remain concrete: preserve program semantics, make dependencies explicit, and avoid repeating work whose inputs have not changed.