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:
- Identifies the imported module + its transitive dependencies.
- Marks them as a separate chunk.
- 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].jsinstead of1234.[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:
- 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.
- 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:
MiniCssExtractPluginextracts CSS into per-chunk.cssfiles; 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.cssfile’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. UsewebpackChunkNamefor 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
modulepreloadlinks 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
- Code-splitting strategy (user-facing): ../15_performance/03_bundle_analysis_and_code_splitting.md
- Lazy loading patterns: ../15_performance/05_lazy_loading.md
- Resource hints (preload/modulepreload): ../15_performance/08_resource_hints.md
- Tree shaking interacts with chunks: ../15_performance/04_tree_shaking.md
Further reading
- Webpack — Code Splitting: https://webpack.js.org/guides/code-splitting/
- Rollup — Output options: https://rollupjs.org/configuration-options/#output-manualchunks
- Vite — Build options: https://vitejs.dev/guide/build.html#chunking-strategy