JavaScript Bundlers: An Advanced Architecture

Deep dive into JavaScript bundler architecture. Covers module resolution, dependency graph construction, transform pipelines, chunk splitting algorithms, plugin systems, hot module replacement internals, and building a minimal bundler from scratch.

JavaScriptadvanced
17 min read

JavaScript bundlers transform a dependency graph of modules into optimized bundles for the browser. Understanding their architecture helps you configure them effectively, write plugins, and choose the right tool.

For comparing specific bundlers, see Webpack vs Vite vs Rollup: JS Bundler Guide.

Bundler Pipeline Overview

Every bundler follows the same core pipeline: resolve an entry point, build a graph of all its dependencies, transform each module through loaders, split the graph into output chunks, generate the final JavaScript, and optimize it. The class below models this flow as a sequence of method calls so you can see how each phase feeds into the next.

javascriptjavascript
// A bundler performs these phases:
// 1. Entry Resolution  -> Find the starting module(s)
// 2. Dependency Graph   -> Recursively resolve all imports
// 3. Transform          -> Compile/transpile each module
// 4. Chunk Splitting    -> Group modules into output chunks
// 5. Code Generation    -> Produce final JavaScript output
// 6. Optimization       -> Minify, tree shake, scope hoist
 
// Simplified pipeline representation
class BundlerPipeline {
  constructor(config) {
    this.entry = config.entry;
    this.output = config.output;
    this.plugins = config.plugins || [];
    this.loaders = config.loaders || [];
  }
 
  async build() {
    // Phase 1: Resolve entry
    const entryModule = await this.resolve(this.entry);
 
    // Phase 2: Build dependency graph
    const graph = await this.buildGraph(entryModule);
 
    // Phase 3: Transform modules
    const transformed = await this.transformAll(graph);
 
    // Phase 4: Split into chunks
    const chunks = this.splitChunks(transformed);
 
    // Phase 5: Generate output
    const output = this.generate(chunks);
 
    // Phase 6: Optimize
    return this.optimize(output);
  }
}
PhaseInputOutputKey Algorithms
ResolutionImport specifierAbsolute file pathNode resolution, exports map
Graph BuildingEntry pathModule dependency treeDFS/BFS traversal, cycle detection
TransformSource codeTransformed code + ASTBabel, SWC, TypeScript compiler
Chunk SplittingFull graphChunk groupsEntry points, dynamic imports, shared modules
Code GenerationChunksBundle stringsModule wrapping, runtime injection
OptimizationRaw bundlesMinified bundlesTerser, scope hoisting, tree shaking

Module Resolution Algorithm

Module resolution takes an import specifier like "./utils" or "lodash" and turns it into an absolute file path. The resolver checks aliases first, then handles relative imports by trying the path with each configured extension and as a directory with an index file. For bare specifiers (package names), it walks up the directory tree looking for node_modules, reads package.json, and respects the modern exports map before falling back to main.

javascriptjavascript
class ModuleResolver {
  constructor(options = {}) {
    this.extensions = options.extensions || [".js", ".jsx", ".ts", ".tsx"];
    this.aliases = options.alias || {};
    this.mainFields = options.mainFields || ["module", "main"];
  }
 
  resolve(specifier, fromFile) {
    // Step 1: Check aliases
    for (const [alias, target] of Object.entries(this.aliases)) {
      if (specifier === alias || specifier.startsWith(alias + "/")) {
        specifier = specifier.replace(alias, target);
        break;
      }
    }
 
    // Step 2: Relative imports (./foo, ../bar)
    if (specifier.startsWith(".")) {
      return this.resolveRelative(specifier, fromFile);
    }
 
    // Step 3: Bare specifiers (node_modules)
    return this.resolvePackage(specifier, fromFile);
  }
 
  resolveRelative(specifier, fromFile) {
    const dir = path.dirname(fromFile);
    const base = path.join(dir, specifier);
 
    // Try exact path
    if (fs.existsSync(base) && fs.statSync(base).isFile()) {
      return base;
    }
 
    // Try with extensions
    for (const ext of this.extensions) {
      const withExt = base + ext;
      if (fs.existsSync(withExt)) return withExt;
    }
 
    // Try as directory with index file
    for (const ext of this.extensions) {
      const indexPath = path.join(base, `index${ext}`);
      if (fs.existsSync(indexPath)) return indexPath;
    }
 
    throw new Error(`Cannot resolve: ${specifier} from ${fromFile}`);
  }
 
  resolvePackage(specifier, fromFile) {
    const parts = specifier.split("/");
    const packageName = specifier.startsWith("@")
      ? parts.slice(0, 2).join("/")
      : parts[0];
    const subpath = parts.slice(packageName.split("/").length).join("/");
 
    // Walk up directories to find node_modules
    let dir = path.dirname(fromFile);
    while (dir !== path.dirname(dir)) {
      const pkgDir = path.join(dir, "node_modules", packageName);
      if (fs.existsSync(pkgDir)) {
        if (subpath) {
          return this.resolveRelative("./" + subpath, path.join(pkgDir, "index.js"));
        }
 
        // Read package.json for entry point
        const pkgJson = JSON.parse(
          fs.readFileSync(path.join(pkgDir, "package.json"), "utf8")
        );
 
        // Check exports map first (modern packages)
        if (pkgJson.exports) {
          return this.resolveExportsMap(pkgDir, pkgJson.exports);
        }
 
        // Fallback to mainFields
        for (const field of this.mainFields) {
          if (pkgJson[field]) {
            return path.join(pkgDir, pkgJson[field]);
          }
        }
 
        return path.join(pkgDir, "index.js");
      }
      dir = path.dirname(dir);
    }
 
    throw new Error(`Package not found: ${packageName}`);
  }
 
  resolveExportsMap(pkgDir, exports) {
    if (typeof exports === "string") {
      return path.join(pkgDir, exports);
    }
 
    // Check conditions: import > module > default
    const conditions = ["import", "module", "default"];
    const entry = exports["."] || exports;
 
    for (const condition of conditions) {
      if (entry[condition]) {
        return path.join(pkgDir, entry[condition]);
      }
    }
 
    return path.join(pkgDir, "index.js");
  }
}

Dependency Graph Construction

The dependency graph is the data structure at the heart of every bundler. Starting from the entry file, it reads each module's source, extracts all import and dynamic import() statements with regex, resolves them to file paths, and repeats until every reachable module has been visited. The graph also provides a topological sort so modules are emitted in the correct order, with dependencies appearing before the modules that use them.

javascriptjavascript
class DependencyGraph {
  constructor(resolver) {
    this.resolver = resolver;
    this.modules = new Map();
    this.edges = new Map();
  }
 
  async build(entryPath) {
    const queue = [entryPath];
    const visited = new Set();
 
    while (queue.length > 0) {
      const currentPath = queue.shift();
      if (visited.has(currentPath)) continue;
      visited.add(currentPath);
 
      const source = fs.readFileSync(currentPath, "utf8");
      const imports = this.extractImports(source);
 
      const resolvedDeps = [];
      for (const imp of imports) {
        try {
          const resolved = this.resolver.resolve(imp.specifier, currentPath);
          resolvedDeps.push({
            ...imp,
            resolvedPath: resolved,
          });
          queue.push(resolved);
        } catch (error) {
          console.warn(`Failed to resolve: ${imp.specifier} from ${currentPath}`);
        }
      }
 
      this.modules.set(currentPath, {
        path: currentPath,
        source,
        imports: resolvedDeps,
      });
 
      this.edges.set(
        currentPath,
        resolvedDeps.map((d) => d.resolvedPath)
      );
    }
 
    return this;
  }
 
  extractImports(source) {
    const imports = [];
 
    // Static imports
    const staticPattern =
      /import\s+(?:\{[^}]+\}|\*\s+as\s+\w+|\w+)?\s*(?:,\s*\{[^}]+\})?\s*from\s+['"]([^'"]+)['"]/g;
    let match;
    while ((match = staticPattern.exec(source)) !== null) {
      imports.push({ specifier: match[1], type: "static" });
    }
 
    // Dynamic imports
    const dynamicPattern = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
    while ((match = dynamicPattern.exec(source)) !== null) {
      imports.push({ specifier: match[1], type: "dynamic" });
    }
 
    return imports;
  }
 
  getModuleOrder() {
    // Topological sort
    const result = [];
    const visited = new Set();
    const visiting = new Set();
 
    const visit = (modulePath) => {
      if (visited.has(modulePath)) return;
      if (visiting.has(modulePath)) return; // Circular dependency
 
      visiting.add(modulePath);
      const deps = this.edges.get(modulePath) || [];
      deps.forEach((dep) => visit(dep));
      visiting.delete(modulePath);
      visited.add(modulePath);
      result.push(modulePath);
    };
 
    for (const modulePath of this.modules.keys()) {
      visit(modulePath);
    }
 
    return result;
  }
 
  getStats() {
    return {
      totalModules: this.modules.size,
      totalEdges: [...this.edges.values()].reduce((s, e) => s + e.length, 0),
      entryPoints: [...this.modules.keys()].filter(
        (p) => ![...this.edges.values()].flat().includes(p)
      ).length,
    };
  }
}

Transform Pipeline

The transform pipeline runs each module through a chain of loaders matched by file extension. A .ts file goes through the TypeScript compiler, while .js and .jsx files go through Babel. Each transform receives the source code and file path, and returns the transformed code plus an optional source map. The pipeline is composable: you add transforms with .use() and they run in order for every matching file.

javascriptjavascript
class TransformPipeline {
  constructor() {
    this.transforms = [];
  }
 
  use(test, transform) {
    this.transforms.push({ test, transform });
    return this;
  }
 
  async process(modulePath, source) {
    let result = { code: source, map: null };
 
    for (const { test, transform } of this.transforms) {
      if (test(modulePath)) {
        result = await transform(result.code, modulePath);
      }
    }
 
    return result;
  }
}
 
// Usage
const pipeline = new TransformPipeline();
 
pipeline
  .use(
    (path) => path.endsWith(".ts"),
    async (code, path) => {
      // TypeScript compilation
      const ts = require("typescript");
      const result = ts.transpileModule(code, {
        compilerOptions: {
          module: ts.ModuleKind.ESNext,
          target: ts.ScriptTarget.ES2020,
          sourceMap: true,
        },
        fileName: path,
      });
      return { code: result.outputText, map: result.sourceMapText };
    }
  )
  .use(
    (path) => /\.(js|jsx)$/.test(path),
    async (code, path) => {
      // Babel transformation
      const babel = require("@babel/core");
      const result = await babel.transformAsync(code, {
        filename: path,
        presets: [["@babel/preset-env", { modules: false }]],
        sourceMaps: true,
      });
      return { code: result.code, map: result.map };
    }
  );

Plugin System Architecture

The plugin system exposes named hooks at every stage of the build pipeline (beforeBuild, afterResolve, beforeTransform, afterEmit, etc.). Plugins register handlers for the hooks they care about, and the bundler runs those handlers in order when it reaches each phase. This is the same architecture webpack and Rollup use internally. The two example plugins below show a bundle analyzer that reports chunk sizes and an environment variable replacer that inlines process.env values at build time.

javascriptjavascript
class PluginSystem {
  constructor() {
    this.hooks = {
      beforeBuild: [],
      afterResolve: [],
      beforeTransform: [],
      afterTransform: [],
      beforeChunk: [],
      afterChunk: [],
      beforeEmit: [],
      afterEmit: [],
    };
  }
 
  registerPlugin(plugin) {
    for (const [hookName, handler] of Object.entries(plugin.hooks || {})) {
      if (this.hooks[hookName]) {
        this.hooks[hookName].push(handler);
      }
    }
  }
 
  async runHook(hookName, context) {
    for (const handler of this.hooks[hookName]) {
      const result = await handler(context);
      if (result !== undefined) {
        context = result;
      }
    }
    return context;
  }
}
 
// Example plugin: Bundle Analyzer
const bundleAnalyzerPlugin = {
  name: "bundle-analyzer",
  hooks: {
    afterChunk(context) {
      const report = {};
      for (const chunk of context.chunks) {
        report[chunk.name] = {
          modules: chunk.modules.length,
          sizeKB: (chunk.code.length / 1024).toFixed(1),
        };
      }
      console.table(report);
      return context;
    },
  },
};
 
// Example plugin: Environment Variables
const envPlugin = {
  name: "env-replace",
  hooks: {
    beforeTransform(context) {
      context.code = context.code.replace(
        /process\.env\.(\w+)/g,
        (match, key) => JSON.stringify(process.env[key] || "")
      );
      return context;
    },
  },
};

Hot Module Replacement Architecture

Hot Module Replacement (HMR) updates running code in the browser without a full page reload. The server watches for file changes, rebuilds the affected module, and pushes the new code over a WebSocket. The client runtime receives the update, runs any dispose handlers to clean up the old module's state, re-executes the module factory, and calls accept handlers so the application can swap in the new exports. If no module in the dependency chain accepts the update, it falls back to a full reload.

javascriptjavascript
// HMR consists of:
// 1. Server: watches files, builds updates, pushes via WebSocket
// 2. Client Runtime: receives updates, swaps modules in memory
// 3. Module API: accept/decline/dispose handlers
 
// Simplified HMR client runtime
class HMRRuntime {
  constructor(wsUrl) {
    this.modules = new Map();
    this.acceptHandlers = new Map();
    this.disposeHandlers = new Map();
    this.ws = new WebSocket(wsUrl);
 
    this.ws.onmessage = (event) => {
      const update = JSON.parse(event.data);
      this.applyUpdate(update);
    };
  }
 
  register(moduleId, factory) {
    this.modules.set(moduleId, {
      factory,
      exports: {},
      hot: {
        accept: (handler) => {
          this.acceptHandlers.set(moduleId, handler);
        },
        dispose: (handler) => {
          this.disposeHandlers.set(moduleId, handler);
        },
      },
    });
  }
 
  async applyUpdate(update) {
    const { moduleId, newCode } = update;
 
    // Step 1: Run dispose handler for old module
    const disposeHandler = this.disposeHandlers.get(moduleId);
    if (disposeHandler) {
      disposeHandler();
    }
 
    // Step 2: Create new module factory
    const newFactory = new Function("module", "exports", "require", newCode);
 
    // Step 3: Check if module self-accepts
    const acceptHandler = this.acceptHandlers.get(moduleId);
 
    if (acceptHandler) {
      // Module handles its own update
      const mod = this.modules.get(moduleId);
      mod.factory = newFactory;
      mod.exports = {};
      newFactory(mod, mod.exports, this.require.bind(this));
      acceptHandler(mod.exports);
      console.log(`[HMR] Updated: ${moduleId}`);
    } else {
      // Bubble up to parent that accepts
      console.log(`[HMR] Full reload needed for: ${moduleId}`);
      location.reload();
    }
  }
 
  require(moduleId) {
    const mod = this.modules.get(moduleId);
    if (!mod) throw new Error(`Module not found: ${moduleId}`);
    return mod.exports;
  }
}
Rune AI

Rune AI

Key Insights

  • Module resolution follows Node's algorithm: Extensions, index files, package.json mainFields, and exports maps determine how import specifiers map to file paths
  • Dependency graphs are built via DFS traversal: The bundler recursively resolves imports, detecting circular dependencies and constructing a topological module order
  • Transform pipelines apply loaders/plugins per file type: Each module passes through matching transforms (TypeScript, Babel, CSS) before entering the chunk splitting phase
  • Plugin hooks intercept every build phase: beforeBuild, afterResolve, beforeTransform, afterEmit hooks let plugins modify behavior at each stage
  • HMR swaps modules in memory without a full reload: The runtime receives updated code via WebSocket, runs dispose handlers, re-executes the module factory, and calls accept callbacks
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between a bundler and a compiler?

compiler transforms source code from one form to another (TypeScript to JavaScript, JSX to function calls). A bundler collects multiple modules into optimized output files. Modern bundlers include compilation as a sub-step in their pipeline. Tools like Babel and SWC are compilers; webpack, Rollup, and Vite are bundlers that use compilers internally.

How do bundlers handle circular dependencies?

Bundlers detect circular dependencies during graph construction (visiting a node already being visited). They handle them by providing partially-initialized module exports. When module A imports from B, and B imports from A, the bundler ensures B receives whatever A has exported so far (which may be incomplete). This is why circular dependencies can cause undefined values.

What makes Vite faster than webpack in development?

Vite serves modules as native ES imports during development, bypassing the bundling step entirely. The browser handles module resolution and loading. Vite only transforms individual files on demand (using esbuild for speed). Webpack bundles everything into a single file on every change, which gets slower as the project grows. Vite only bundles for production using Rollup.

How does scope hoisting improve bundle size?

Scope hoisting (or module concatenation) inlines module code into a single scope instead of wrapping each module in a function. This eliminates the per-module function wrapper overhead (typically 100-200 bytes each) and enables the minifier to perform cross-module optimizations like variable name shortening and dead code elimination.

When should I write a custom bundler plugin?

Write a custom plugin when you need to transform non-standard file types (custom template languages), inject build-time data (git hash, build timestamp), perform custom validations (import restrictions, size budgets), or integrate with internal tools (deployment, analytics). Start with the plugin documentation and existing plugin source code as reference.

Conclusion

Understanding bundler architecture reveals why certain configurations work and helps you debug build issues. The pipeline of resolution, graph construction, transformation, chunking, and optimization forms the core of every bundler. For comparing webpack, Vite, and Rollup, see Webpack vs Vite vs Rollup: JS Bundler Guide. For tree shaking details, see JavaScript Tree Shaking: A Complete Tutorial.