Frontend / Build tools / 04_code_splitting_mechanics.md

Code Splitting Mechanics — Dynamic Imports, Manual Chunks

Updated 5 interview angles 6 min read source
On this page14
  1. How does dynamic import() cause a code split?
  2. What modules do not split?
  3. Webpack magic comments.
  4. Manual chunks — when and how?
  5. When does manual chunking hurt?
  6. How does the bundler load split chunks at runtime?
  7. What’s the difference between code splitting and bundling?
  8. How does this interact with SSR / RSC?
  9. What about CSS code splitting?
  10. Common chunk-naming patterns.
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Code Splitting Mechanics — Dynamic Imports, Manual Chunks

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 Bundle Analysis and Code Splitting; this file is the build-tool mechanics.

How does dynamic import() cause a code split?

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.
ts
// app.ts
async function loadEditor() {
  const { Editor } = await import("./Editor");
  return new Editor();
}

Bundler output:

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

What modules do not split?

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

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

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

Webpack magic comments.

Hints embedded in dynamic imports to control chunk behavior:

ts
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).

Manual chunks — when and how?

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.
ts
// 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"],
        },
      },
    },
  },
});

The function form

Or dynamic (the function form):

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

In Webpack

Webpack’s equivalent is optimization.splitChunks.cacheGroups:

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

When does manual chunking hurt?

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.

How does the bundler load split chunks at runtime?

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.

What’s the difference between code splitting and bundling?

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

How does this interact with SSR / RSC?

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.

What about CSS code splitting?

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

Common chunk-naming patterns.

Hashed filenames for long-term cache:

text
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

  • 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 Lazy Loading — Route, Component, Interaction).
  • 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.

Further reading

Interview angle 5

  • “What actually creates a chunk?” - a dynamic import(). It is the only signal in the language that says “this may load later”, so a static import is always merged into the importing chunk no matter how large it is.
  • “When do you configure manual chunks?” - after measuring, not by default. Splitting vendors into their own long-lived chunk was worth much more on HTTP/1.1 with a small connection budget; over HTTP/2 with a hashed-filename cache strategy it often makes things slightly worse by adding round trips.
  • “How do you avoid a request waterfall from a dynamic import?” - modulepreload links, which Vite emits automatically for dynamic imports it can see statically. Without them the browser only discovers the chunk after the parent module executes, which is one round trip too late.
  • “Why name your chunks?” - so that a DevTools trace and a bundle analyser report are readable. Debugging “why is chunk-4f2a.js 400 KB” is a different exercise from seeing editor in the list.
  • “How do code splitting and RSC relate?” - they are complementary. RSC removes server-only code from the client bundle entirely; dynamic imports load the client code that remains on demand. Neither substitutes for the other.