frontend / build tools / 04_code_splitting_mechanics.md

Code Splitting Mechanics — Dynamic Imports, Manual Chunks

6 min read source

Code Splitting Mechanics — Dynamic Imports, Manual Chunks

TL;DR

A chunk is a bundle file produced by your bundler. Dynamic import() is the language primitive that lets the bundler split a chunk — code only reachable through await import(...) becomes a separate file, loaded on demand. Manual chunks (bundler config) give you control over which modules group together — useful for stable vendor bundles or domain-aligned grouping. The user-facing impact lives in ../15_performance/03_bundle_analysis_and_code_splitting.md; this file is the build-tool mechanics.

Interview Q&A

Q: How does dynamic import() cause a code split?

A: The bundler statically analyzes your code. When it encounters import(...), it:

  1. Identifies the imported module + its transitive dependencies.
  2. Marks them as a separate chunk.
  3. Replaces the call with runtime code that fetches the chunk file.
// app.ts
async function loadEditor() {
  const { Editor } = await import("./Editor");
  return new Editor();
}

Bundler output:

dist/
  app.[hash].js           # main bundle — doesn't include Editor
  Editor.[hash].js        # chunk — code-split

At runtime, loadEditor() injects <script src="Editor.[hash].js"> (or via fetch + eval/import()), waits for it, then returns the module.

Q: What modules do not split?

A: Static import statements are always in the same chunk as the importer:

import { Editor } from "./Editor";    // always in the importing chunk

If you want code-split, you must use dynamic import() — that’s the only language signal the bundler reads.

Q: Webpack magic comments.

A: Hints embedded in dynamic imports to control chunk behavior:

const Editor = await import(
  /* webpackChunkName: "editor" */
  /* webpackPrefetch: true */
  /* webpackPreload: false */
  "./Editor"
);
  • webpackChunkName — gives the chunk a stable name (editor.[hash].js instead of 1234.[hash].js).
  • webpackPrefetch — emits <link rel="prefetch"> for the chunk (low-priority fetch on idle).
  • webpackPreload — emits <link rel="preload"> (high-priority fetch in parallel with main bundle).
  • webpackMode: "lazy" | "eager" | "lazy-once" — controls how the chunk is included.

Vite/Rollup support similar but via config (output.chunkFileNames, output.manualChunks).

Q: Manual chunks — when and how?

A: Tell the bundler to group specific modules into named chunks regardless of import structure. Useful for:

  • Stable vendor bundle (libraries change less often than app code).
  • Domain-aligned grouping (e.g., “admin features” in one chunk).
  • Splitting a too-large chunk the bundler created.
// vite.config.ts (Rollup options)
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          react: ["react", "react-dom"],
          tanstack: ["@tanstack/react-query", "@tanstack/react-virtual"],
          charts: ["d3", "recharts"],
        },
      },
    },
  },
});

Or dynamic (the function form):

manualChunks(id: string) {
  if (id.includes("node_modules")) {
    if (id.includes("react")) return "react";
    return "vendor";
  }
}

The function runs for every module; return value = chunk name.

Webpack’s equivalent is optimization.splitChunks.cacheGroups:

optimization: {
  splitChunks: {
    cacheGroups: {
      react: {
        test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
        name: "react",
        chunks: "all",
        priority: 10,
      },
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name: "vendor",
        chunks: "all",
      },
    },
  },
},

Q: When does manual chunking hurt?

A: Two cases:

  1. Cache invalidation cascades. A 500 KB vendor bundle that updates whenever any vendor dep updates — repeat visitors keep refetching. Smaller chunks invalidate less often, on average.
  2. HTTP/2 changed the math. Many small chunks aren’t worse than a few big ones (no per-request connection overhead). Aggressive chunking lost some of its value.

For HTTP/1.1 + cold cache + few-update vendor → manual chunking wins. For HTTP/2 + frequent vendor updates + content hashes → automatic chunks are usually fine.

Measure with webpack-bundle-analyzer / rollup-plugin-visualizer before tuning.

Q: How does the bundler load split chunks at runtime?

A: Webpack injects a runtime that maintains a chunk registry. Calling import("./X") looks up X’s chunk URL, injects a <script> tag (or uses native import() in ESM mode), waits for the chunk to register its exports, then resolves the promise.

In Vite/Rollup ESM output, dynamic import() works natively — the browser fetches the chunk URL. The bundler doesn’t need its own runtime for this (ESM dynamic imports are first-class).

Vite emits <link rel="modulepreload"> tags for statically-known dynamic imports, so the browser can fetch in parallel with main bundle parsing. This is the “shouldn’t have a waterfall” win.

Q: What’s the difference between code splitting and bundling?

A:

  • Bundling: combining many small source files into fewer larger output files (for network efficiency, dependency resolution).
  • Code splitting: deliberately not combining everything — keeping some output files separate so they load on demand.

A non-split bundle is “all in one.” A heavily-split bundle is “many on-demand chunks.” Modern apps want both: a small initial bundle that loads fast, plus on-demand chunks for features.

Q: How does this interact with SSR / RSC?

A: In SSR, all code runs on the server, so code splitting in the traditional sense doesn’t reduce server work. But:

  • Client bundle splitting still applies — code only used by client-only components is split.
  • RSC: server-only components never ship to the client (zero client cost for their code). The “split” is structural, not via dynamic import.
  • next/dynamic({ ssr: false }) is the way to keep a heavy component out of SSR and only client-load it.

For Next.js App Router: server components handle most “split by purpose”; dynamic import is for client-side on-demand loading.

Q: What about CSS code splitting?

A:

  • Webpack: MiniCssExtractPlugin extracts CSS into per-chunk .css files; the runtime loads them alongside the chunk’s JS.
  • Vite: built-in CSS code splitting per chunk; entry HTML preloads them.
  • CSS Modules: each .module.css file’s CSS is included in the chunk that imports it. Dynamic-imported components → dynamic-imported CSS.

Critical CSS extraction (inlining the above-the-fold styles in <head>) is a separate concern — critters, beasties, or framework-specific tooling.

Q: Common chunk-naming patterns.

A: Hashed filenames for long-term cache:

dist/
  app.[hash].js              # main entry
  vendor.[hash].js           # node_modules
  Editor.[hash].js           # named via webpackChunkName / manual chunks
  HeavyChart-[hash].js

The hash invalidates the cache when the chunk’s content changes. Content-hash (the default) ensures only the changed chunks need re-downloading.

output.filename / output.chunkFileNames control the pattern.

Gotchas / edge cases

  • Dynamic import() with a runtime path can produce surprising bundling. import(./pages/${name}) may create chunks for every file matching the pattern; the bundler can’t know which one you’ll request.
  • Re-using a chunk across pages — modules imported statically from multiple chunks get hoisted into a shared chunk by the bundler.
  • import.meta.glob (Vite) lets you statically discover multiple modules — useful for plugins, dynamic routes — and code-splits each.
  • Chunk load failures — network drops, deploy invalidated old hash. Wrap with retry / refresh UX (see ../15_performance/05_lazy_loading.md).
  • Webpack’s default chunk-naming uses incremental IDs (1.[hash].js, 2.[hash].js) — opaque in DevTools. Use webpackChunkName for readability.
  • CSS-in-JS libraries (Emotion, styled-components) generate runtime CSS — they ship the runtime once, individual chunks add minimal extra. Server extract is per-bundle.

What a senior is expected to say

  • “Dynamic import() is the only language signal that produces a chunk. Static imports are always in the importer’s chunk.”
  • “Manual chunks are a tuning knob, not a default — they were more valuable on HTTP/1.1 with stable vendor caching. Measure before configuring.”
  • “Vite emits modulepreload links for known dynamic imports, removing the discovery waterfall.”
  • “Naming chunks (webpackChunkName, manual chunks) makes DevTools traces and bundle analyzer output readable.”
  • “Code splitting + RSC are complementary — RSC removes server-only code from the client; dynamic imports load client code on demand.”

Cross-references

Further reading