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:
- 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-splitAt 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:
// 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:
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).
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.
// 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):
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:
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:
- 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.
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:
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.
Common chunk-naming patterns.
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].jsThe 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. 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.
Related
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
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?” -
modulepreloadlinks, 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.js400 KB” is a different exercise from seeingeditorin 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.