Webpack Chunk Generation Lifecycle Explained

Without deliberate control of Webpack 5’s compilation pipeline, a medium-sized React application ships an undifferentiated 800 KB initial bundle: no shared vendor isolation, no async boundaries, and every content-hash invalidated on each deploy. That means 1–2 second TTI regressions on 3G connections and a CDN cache-hit rate below 30 % because users re-download unchanged dependencies on every release. Understanding each phase of the lifecycle — from module graph construction through chunk graph assembly, asset emission, and runtime injection — gives you the leverage to reduce initial JS payload by 50–65 % and push long-term cache efficiency above 85 %.

This page sits within the JavaScript Build Pipeline & Module Resolution Fundamentals guide, which covers the full spectrum of bundler internals from module resolution to source map generation. The four lifecycle phases below map directly to Webpack’s internal compilation hooks and translate into concrete configuration changes.

Compilation pipeline overview

The diagram below shows how a single webpack --config invocation moves from entry points to emitted assets through four distinct phases, each controlled by a separate set of hooks and plugins.

Webpack 5 Compilation Pipeline Four sequential phases: Module Graph Construction, Chunk Graph Assembly, Asset Emission, and Runtime Injection, connected by arrows showing data flow from entry points to final output assets. Phase 1 Module Graph Construction NormalModuleFactory Phase 2 Chunk Graph Assembly SplitChunksPlugin Phase 3 Asset Emission & Hashing TerserPlugin · emit hook Phase 4 Runtime Injection webpack/runtime/ entry points dist/ assets

Phase 1: Module graph construction

The lifecycle begins with NormalModuleFactory parsing every entry point and walking the import graph. Each import or require() becomes a directed edge in a DAG; each resolved file becomes a node that carries its loader-transformed source, export names, and side-effect metadata. The build pipeline fundamentals article explains how Webpack applies alias resolution and extension priority before this graph walk begins — misconfigured aliases at that stage surface as Module not found errors here.

Loader chains execute per-node during graph construction: ts-loader transpiles TypeScript, babel-loader adds polyfills, CSS loaders convert stylesheets to JS module wrappers. The output of each loader becomes the source Webpack stores in the module registry.

Webpack 5 configuration

// Webpack 5 — webpack.config.js
const path = require('path');

module.exports = {
  resolve: {
    alias: {
      '@utils': path.resolve(__dirname, 'src/utils'), // normalise import paths
      'react': 'preact/compat'                        // module substitution
    },
    extensions: ['.ts', '.tsx', '.js']
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: ['ts-loader'],    // single loader; avoid chaining ts-loader + babel-loader unnecessarily
        exclude: /node_modules/
      }
    ]
  },
  externals: {
    react: 'React',       // bypass bundling; resolved at runtime via CDN/globals
    'react-dom': 'ReactDOM'
  }
};

Vite 5+ equivalent

// Vite 5+ — vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    alias: {
      '@utils': '/src/utils'
    }
  },
  // Vite pre-bundles node_modules with esbuild; externals are declared per build target
  build: {
    rollupOptions: {
      external: ['react', 'react-dom']
    }
  }
});

Vite’s on-demand graph differs fundamentally from Webpack’s: during development it constructs the graph lazily per browser request rather than eagerly upfront, as detailed in Vite module graph and dependency resolution. The production build still produces a full Rollup graph before chunking.

Performance impact of this phase

Deep dependency trees increase traversal time by roughly 15–40 ms per 1,000 modules. Overusing externals shrinks graph traversal time but shifts resolution to runtime: if CDN caching is misaligned, each external adds 50–120 ms to TTFB on first load.

Phase 2: Chunk graph assembly

Once the flat module registry is complete, SplitChunksPlugin partitions it into chunk nodes. The algorithm evaluates each module’s size, the number of entry points that share it (minChunks), and the cache group rules you declare. Modules below minSize are inlined rather than split. Dynamic import() calls automatically create async chunk boundaries — each becomes a Promise-linked child node in the chunk graph.

Module format matters critically in this phase. As covered in understanding ES modules vs CommonJS in bundlers, ESM’s static shape allows Webpack to determine exact exports at build time and draw tight chunk boundaries. CJS require() calls force Webpack to wrap the entire module in a runtime object, inflating the chunk payload and preventing precise splitting.

Webpack 5 configuration

// Webpack 5 — webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 20000,         // 20 KB minimum before a split is worthwhile
      maxAsyncRequests: 30,   // cap concurrent async chunk requests
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: 10,
          reuseExistingChunk: true  // avoid duplicating a module already in another chunk
        },
        common: {
          minChunks: 2,       // must be shared by at least 2 entry points
          priority: 5,
          reuseExistingChunk: true
        }
      }
    },
    runtimeChunk: 'single'    // hoist bootstrap into a shared file (see Phase 4)
  }
};

Vite 5+ equivalent

// Vite 5+ — vite.config.js (Rollup manualChunks)
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            return 'vendor'; // all node_modules → single vendor chunk
          }
        }
      }
    }
  }
};

Framework integration — React

// React lazy + Suspense — creates an async chunk boundary at the import() call
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() =>
  import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
);

export function App() {
  return (
    <Suspense fallback={<div>Loading…</div>}>
      <Dashboard />
    </Suspense>
  );
}

Framework integration — Vue 3

// Vue 3 defineAsyncComponent — equivalent async chunk boundary
import { defineAsyncComponent } from 'vue';

const Dashboard = defineAsyncComponent(() =>
  import(/* webpackChunkName: "dashboard" */ './pages/Dashboard.vue')
);

Performance impact of this phase

  • Aggressive splitting targeting ≤150 KB initial JS reduces first-load payload by 30–60 % compared to a single monolithic bundle.
  • Each additional async chunk adds approximately 20–40 ms of network round-trip overhead; cap maxAsyncRequests to avoid trading payload reduction for waterfall depth.
  • Cache group priority conflicts cause duplicate modules: the same module inlined in both an async and a vendor chunk can inflate total download size by 15–25 %.

Phase 3: Asset emission and content hashing

With the chunk graph frozen, Webpack’s Compilation object iterates every chunk node, serialises its modules into JavaScript, and triggers the emit hook chain. TerserPlugin minifies each asset in parallel worker threads during this phase. Webpack 5 then stamps each output file with a contenthash derived from the exact bytes in that file — independent of other chunks, so a change to one module only invalidates the hash of the chunks that contain it.

Unlike the full-batch emission Webpack performs here, Vite’s module graph defers deep analysis to production builds and relies on Rollup’s tree-shaker during code generation. Both approaches produce contenthash-equivalent fingerprints; the difference lies in memory cost: Webpack holds the entire serialised compilation in memory simultaneously.

Source map generation runs in parallel. Choosing the right devtool value dramatically affects build time and map fidelity — the source map generation and debugging workflows page covers the full tradeoff matrix. In production, hidden-source-map writes .map files to your error-tracking service without exposing them via the bundle.

Webpack 5 configuration

// Webpack 5 — webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: { drop_console: true },
          mangle: true,
          format: { comments: false }
        },
        parallel: true,         // multi-core serialisation
        extractComments: false
      })
    ]
  },
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    clean: true
  },
  devtool: process.env.NODE_ENV === 'production'
    ? 'hidden-source-map'
    : 'eval-source-map'
};

Vite 5+ equivalent

// Vite 5+ — vite.config.js
export default {
  build: {
    sourcemap: process.env.NODE_ENV === 'production' ? 'hidden' : true,
    minify: 'terser',       // or 'esbuild' for faster builds at slight size cost
    terserOptions: {
      compress: { drop_console: true }
    },
    rollupOptions: {
      output: {
        entryFileNames: '[name].[hash].js',
        chunkFileNames: '[name].[hash].chunk.js'
      }
    }
  }
};

Performance impact of this phase

  • Full parallel minification reduces gzipped payload by 40–65 % versus unminified output, at the cost of 3–8× longer build time.
  • contenthash enables Cache-Control: max-age=31536000, immutable for all chunk files; correct hashing yields CDN cache-hit rates above 85 % for returning users.
  • Source map generation increases peak build memory by 200–400 MB per 10 MB of source; disable in local dev if memory is constrained.

Phase 4: Runtime injection and chunk loading

The Webpack runtime (webpack/runtime/) is a small bootstrap injected into the entry chunk or into its own isolated file. It maintains the installedChunks registry, manages the async chunk loading queue via fetch() or <script> injection, and implements __webpack_require__ for synchronous module resolution. Module execution order is strictly topological: no module runs before its dependencies are installed in the registry.

Isolating the runtime with runtimeChunk: 'single' is critical in multi-entry configurations. Without isolation, each entry embeds its own copy of the bootstrap, which inflates every entry chunk by ~4 KB uncompressed and breaks shared installedChunks state when two entry points run on the same page (common in micro-frontend architectures). When source maps are misconfigured in production, runtime stack frames point to minified bootstrap code rather than original files — the fixing source map mismatches in Webpack 5 page covers the exact diagnostic and corrective steps.

Webpack 5 configuration

// Webpack 5 — webpack.config.js
module.exports = {
  output: {
    publicPath: process.env.CDN_URL || '/assets/', // must match CDN origin exactly
    chunkLoadingGlobal: 'webpackChunk_myapp'        // unique global name prevents micro-frontend collisions
  },
  optimization: {
    runtimeChunk: 'single'
  }
};

// Dynamic import with magic comment — ties chunk name to cache group logic
const loadDashboard = () =>
  import(/* webpackChunkName: "dashboard" */ './pages/Dashboard');

Vite 5+ equivalent

// Vite 5+ — no explicit runtimeChunk option; Vite inlines a minimal bootstrap per entry
// For micro-frontend isolation use the federation plugin instead
export default {
  build: {
    rollupOptions: {
      output: {
        // Vite uses a shared preload-helper chunk automatically
        generatedCode: { constBindings: true }
      }
    }
  }
};

Performance impact of this phase

  • A single isolated runtime chunk adds roughly 1.5 KB gzipped but eliminates duplicate bootstrap overhead across entries, reducing total initial JS by 4–12 KB in multi-entry apps.
  • A misconfigured publicPath causes every async chunk request to 404, breaking app initialisation entirely — the failure is silent until the first import() boundary is crossed.
  • Runtime chunk placement matters: blocking <script> in <head> delays FCP by 100–300 ms compared to a defer-loaded runtime injected via the HtmlWebpackPlugin default.

Quantified impact of the full lifecycle

  • Initial payload: Correct chunk graph partitioning with vendor isolation and runtimeChunk: 'single' reduces initial JS from ~800 KB to ~280–350 KB for a typical React app (55–65 % reduction).
  • Cache longevity: contenthash with per-asset granularity pushes CDN cache-hit rates from ~30 % (chunkhash-based or un-hashed) to 85–92 % for unchanged chunks across deploys.
  • TTI on 3G: Reducing initial JS below 150 KB (compressed) cuts TTI from ~3.8 s to ~1.6 s on a 1.5 Mbps connection.
  • Build memory: Parallel minification (TerserPlugin with parallel: true) cuts peak build memory by 25–40 % versus single-threaded serialisation for large applications.
  • Duplicate code: Setting reuseExistingChunk: true on all cache groups eliminates module duplication, keeping total bundle size within 5 % of the theoretical minimum.

Common pitfalls

Cache group priority collision

Root cause: Two cache groups with the same priority value both match a module; Webpack picks the first match arbitrarily, causing the module to miss the intended group. Diagnostic signal: stats.json shows the same moduleId in two chunk modules arrays. Fix: Assign explicit priority values with gaps (e.g. vendor: 20, common: 10, default: 0). Re-run the build and confirm the module appears in exactly one chunk.

Incorrect publicPath in containerised CI

Root cause: The runtime bootstrap hardcodes publicPath at compile time. If CDN_URL is unset in CI, chunks are requested from /assets/ but served from a different origin. Diagnostic signal: ChunkLoadError: Loading chunk X failed in the browser console immediately after deploy. Fix: Set output.publicPath = 'auto' for environments where the serving origin is determined at runtime, or ensure CDN_URL is injected as a CI environment variable before the build step.

Over-splitting async chunks

Root cause: Setting maxAsyncRequests above 30 or using fine-grained manualChunks without size thresholds creates dozens of tiny async chunks. Diagnostic signal: Network waterfall shows 20+ parallel chunk fetches on the first route transition; LCP degrades despite smaller individual files. Fix: Enforce minSize: 20000 and cap maxAsyncRequests: 6 for mobile-first targets. Use the bundle analyser to identify chunks below 10 KB and merge them back.

Runtime duplication across micro-frontends

Root cause: Each micro-frontend compiles its own Webpack runtime with a default chunkLoadingGlobal of webpackChunk. When two micro-frontends mount on the same page, their installedChunks registries overwrite each other. Diagnostic signal: Modules from one micro-frontend are silently replaced by identically-named modules from another; React renders the wrong component version. Fix: Set a unique output.chunkLoadingGlobal per micro-frontend (e.g. webpackChunk_shell, webpackChunk_checkout) and use Module Federation’s shared scope for genuinely shared dependencies.

Verification workflow

Step 1 — Generate stats.json

// Webpack 5 — webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  stats: {
    preset: 'verbose',
    assets: true,
    modules: true,
    chunks: true,
    reasons: true,
    optimizationBailout: true
  },
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'disabled',   // headless in CI
      generateStatsFile: true,
      statsFilename: 'stats.json'
    })
  ]
};

Step 2 — Check for duplicate modules

Inspect stats.json with the analyser or run a Node.js script to find any moduleId that appears in more than one chunk’s modules array. A well-configured build should have zero duplicate modules above 5 KB.

Step 3 — Verify content hashes

Run two consecutive builds with no source changes. All output filenames should be identical between both runs. If any hash changes without a source change, check for non-deterministic optimization.moduleIds — Webpack 5 defaults to 'deterministic', which is correct; revert any override to 'named' that is not development-only.

Step 4 — Gate in CI

# .github/workflows/bundle-audit.yml
name: Bundle Size Audit
on: [pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - uses: preactjs/compressed-size-action@v2
        with:
          repo-token: "${{ secrets.GITHUB_TOKEN }}"
          pattern: "./dist/**/*.{js,css}"
          threshold: "5%"
      - name: Enforce bundle budgets
        run: |
          INITIAL_SIZE=$(find dist -name "main.*.js" -exec stat -c%s {} \; | sort -nr | head -1)
          ASYNC_CHUNKS=$(find dist -name "*.chunk.js" 2>/dev/null | wc -l)
          [ -z "$INITIAL_SIZE" ] || [ "$INITIAL_SIZE" -le 153600 ] || { echo "FAIL: initial JS exceeds 150 KB"; exit 1; }
          [ "$ASYNC_CHUNKS" -le 6 ] || { echo "FAIL: async chunk count exceeds 6"; exit 1; }

FAQ

Why does Webpack emit duplicate modules across chunks?

Duplicate modules occur when SplitChunksPlugin cache group priorities overlap or minChunks thresholds are misaligned. A module shared by two async chunks but below the minChunks threshold is inlined into both rather than hoisted. Set reuseExistingChunk: true and verify with stats.json that the same module ID does not appear in multiple chunk assets.

What is runtimeChunk: 'single' and when should I use it?

runtimeChunk: 'single' extracts Webpack’s bootstrap (the installedChunks map, chunk loading queue, and __webpack_require__ implementation) into a single shared file rather than inlining it in every entry chunk. Use it whenever you have more than one entry point to prevent duplicate runtime code and ensure consistent module IDs across entries.

How does contenthash differ from chunkhash in Webpack 5?

chunkhash is derived from the entire chunk’s content, so a change in any module within the chunk invalidates every file in that chunk — including unchanged CSS or other assets. contenthash is computed per asset type, so a CSS change only busts the CSS file’s hash while the JS hash remains stable. Use [contenthash:8] for fine-grained long-term caching.


Related