Code Splitting Strategies for Large Applications
Code splitting partitions a single module graph into multiple chunks that load on demand, so the initial document downloads only the JavaScript needed to render the first route instead of the entire application. Done well, it cuts Time to Interactive on cold loads; done carelessly, it shatters the graph into hundreds of tiny chunks that serialize over the network and inflate per-request overhead. For the dependency-graph model that every splitting decision builds on, read Core Concepts of Modern Bundling before tuning chunk boundaries — splitting is only as deterministic as the graph resolution underneath it.
The problem exists because a single-page application is, by default, one entry point that statically imports every screen, every modal, every charting library and date formatter the app will ever render. The bundler walks that graph from the root and, absent any split points, concatenates the reachable modules into one file. A user who lands on the login screen still pays the download, parse, and compile cost of the admin dashboard, the reporting engine, and the settings panel they may never open. On a cold 4G connection the difference is not marginal: parse and compile of JavaScript is single-threaded on the main thread, so a 1.2 MB bundle can block interactivity for well over a second on a mid-range phone even after the bytes have arrived. Code splitting is the mechanism that lets the graph stay whole in source while the output is carved along the seams where the user’s attention actually moves — routes, rarely used features, and third-party code that changes on a different cadence than your own.
Where this sits in the build pipeline matters for reasoning about it. Splitting is an output concern: it runs after module resolution and tree-shaking have already decided which modules survive, and it decides only how the surviving modules are grouped into files and how those files are named and hashed. That ordering is the source of most confusion in this topic — people expect manualChunks to control what ships, when it only controls where the shipped code lands. Keep the two phases separate in your head and every failure mode below becomes legible.
This guide covers the conceptual mechanics of chunk generation, runnable Vite and Rollup configuration, a numbered workflow with verification commands, the failure modes you will actually hit in production, and the measurement loop that tells you whether a split helped or hurt.
Prerequisites
This guide assumes the following versions. Behaviour differs across majors, especially manualChunks semantics and the default assetFileNames layout. Rollup 3 accepted manualChunks as a top-level option in some contexts; Rollup 4 fixed its home under output, and Vite forwards it there verbatim. If you pin to older majors the config below will still parse but the shared-chunk hoisting heuristics changed subtly between Rollup 3 and 4, so a treemap taken on one major is not directly comparable to the other.
- Node 18.18+ or 20+ (Vite 5/6 drop Node 16).
- Vite 5.x or 6.x — production builds delegate to Rollup, so every Rollup output option is reachable under
build.rollupOptions. - Rollup 4.x —
manualChunksaccepts both the object and function forms; the function receives(id, { getModuleInfo, getModuleIds }). - A React, Vue, or framework-agnostic SPA that already uses dynamic
import()for at least one route. If you have not introducedimport()yet, start with Dynamic import() code splitting patterns for React, which coversReact.lazyandSuspenseboundaries.
Install the visualizer used throughout for verification:
# Vite 5.x / Rollup 4.x
npm i -D rollup-plugin-visualizer@5
import() and the visualizer before any split is measurable.Core Mechanics of Chunk Generation
A chunk is a node in the output graph: a set of modules emitted to one file. Bundlers derive chunks from three signals, evaluated in this order.
- Entry points. Each
inputand eachindex.htmlscript tag seeds an entry chunk. Everything statically reachable from an entry, and not pulled into another chunk, lands here. - Dynamic import boundaries. Every distinct
import('./X')with a statically resolvable specifier becomes a split point. Rollup creates an async chunk for the target and its private subgraph. Modules imported by two or more async chunks are hoisted into a shared chunk to prevent duplication — this hoisting is automatic and is the mechanism most people fight when they see duplicate vendor code. manualChunksoverrides. This output option lets you force specific modules into named chunks regardless of the automatic algorithm. The object form maps a chunk name to an array of module ids; the function form returns a chunk name (orundefinedto defer to the default) for each module id.
The function form is graph-aware. It receives getModuleInfo(id), exposing importers and dynamicImporters, so you can decide based on how many chunks reference a module. This is the lever for both vendor splitting and for fixing the duplication problem covered in Fixing vendor chunk duplication with manualChunks. The distinction between importers and dynamicImporters is worth internalizing: a module reached only through import() boundaries is a candidate for its own async chunk, whereas a module reached statically from several entries is a candidate for hoisting into a shared chunk. When your function returns a name, Rollup treats that name as a hint that creates a chunk if one does not exist, then routes the module into it — but it will still refuse to place a module somewhere that would break the guarantee that a chunk’s dependencies are available before the chunk executes.
A critical constraint: manualChunks only influences placement, never reachability. Tree-shaking still runs first. If a module is unused it is pruned before chunk assignment, so aligning split boundaries with sideEffects declarations matters — see Tree-Shaking Mechanics and Dead Code Elimination. The choice between ESM and CJS also constrains splitting: CJS require() is resolved at runtime, so a CJS-heavy dependency cannot be split as precisely as an ESM one. Understanding ESM vs CommonJS in Modern Bundlers covers why.
How chunk hashing works under the hood
The [hash] token in chunkFileNames is not a random build id — it is a content hash derived from the final, rendered bytes of the chunk after minification and after the imports it references have themselves been resolved to their hashed filenames. That last clause is the important one and the source of a phenomenon called hash cascading. A chunk’s hash incorporates the filenames of the chunks it imports, so when a leaf chunk changes, its new hash changes the import string inside every chunk that references it, which changes those chunks’ hashes in turn, propagating up the graph to the entry. In practice this means a one-line change to a shared utility can invalidate the entry chunk’s cache entry even though the entry’s own source did not change. This is expected behaviour, not a bug, and it is precisely why you isolate rarely changing third-party code into its own vendor chunk: you want the cascade to stop at a boundary that does not move on every deploy.
Shared-chunk hoisting runs on a reachability count. When Rollup finds a module imported by two or more async chunks, it extracts that module (and the private subgraph it owns exclusively) into a synthetic shared chunk so the bytes ship once. The threshold is two importers by default; you cannot tune the count directly, but you can override the outcome with manualChunks by forcing the module into a named chunk regardless. The ordering to remember: tree-shaking prunes, then the automatic hoister groups by reachability, then manualChunks overrides placement, then filenames are hashed last. Every surprising output can be explained by walking those four phases in order.
When not to split
Splitting has a floor below which it costs more than it saves. A route whose entire chunk is under roughly 10 KB gzipped is usually not worth its own network round trip, its own HTTP request, and its own entry in the module preload manifest — the fixed overhead of fetching and instantiating a chunk dominates the savings. Small utility routes, error pages, and thin wrappers belong in the entry or a feature chunk, not in their own async boundary. Likewise, do not split code that is on the eager critical path anyway: if the first paint needs a module, moving it behind an import() only adds a waterfall hop, because the runtime must download the entry, discover the dynamic import, then make a second request before it can render. Split where the user’s navigation gives you a natural asynchronous seam, not everywhere a seam is syntactically possible.
Configuration & CLI Reference
Vendor splitting with the function form (Vite)
The function form gives per-module control. This config isolates React into its own long-lived chunk and groups remaining third-party code, while leaving application route chunks to the automatic algorithm.
// vite.config.ts — Vite 5.x / Rollup 4.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
// Warn earlier than the 500 KB default so regressions surface in CI logs.
chunkSizeWarningLimit: 250,
rollupOptions: {
output: {
// Stable names so CDN cache keys survive unrelated app changes.
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
manualChunks(id) {
if (!id.includes('node_modules')) return;
// Keep React + the renderer together: they version in lockstep.
if (id.includes('react') || id.includes('scheduler')) {
return 'vendor-react';
}
if (id.includes('react-router')) return 'vendor-router';
// Everything else third-party shares one chunk.
return 'vendor';
},
},
},
},
});
A subtle trap lives in the id.includes('react') test: substring matching on the module path is blunt. A package named react-icons or a transitive dependency with react anywhere in its resolved path also matches, so it is pulled into vendor-react even though it does not version in lockstep with the renderer. For a handful of packages this is harmless; once your dependency list grows, prefer anchoring the match to a path segment — testing for /node_modules/react/ and /node_modules/react-dom/ explicitly rather than the bare substring. The scheduler package is grouped alongside React deliberately: it is React’s cooperative scheduling runtime and ships a matching version, so splitting it away from React buys nothing and risks a version-skew load order bug. Setting chunkSizeWarningLimit to 250 rather than leaving the 500 KB default is an opinion, not a requirement: it makes the build print a warning while a chunk is merely large instead of waiting until it is already a problem, which is the kind of signal you want surfacing in a CI log where a human will read it.
Object form (explicit, deterministic grouping)
The object form is simpler and fully deterministic. Use it when you know exactly which packages co-version and want zero per-id logic.
// vite.config.ts — Vite 5.x / Rollup 4.x
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'scheduler'],
'vendor-router': ['react-router', 'react-router-dom'],
'vendor-charts': ['d3', 'recharts'],
},
},
},
},
});
The object form’s determinism is its selling point and its limitation. Because it maps a fixed name to a fixed list of package entry points, the output is identical across builds regardless of how the graph evolves, which makes chunk-list diffs in CI trivial to read. The cost is that it cannot react to the graph: if d3 is suddenly imported by only one route you will not notice it no longer needs its own vendor-charts chunk, because the object form has no visibility into importer counts. A reasonable policy in a large codebase is to hand the object form the two or three dependency groups you are certain about — the framework runtime, the router, a heavy visualization library — and let the automatic algorithm handle the remaining scatter of small packages rather than enumerating every one by hand.
Standalone Rollup build
When Rollup runs without Vite, the same output options apply directly. The graph-aware variant uses getModuleInfo to pull only widely shared utilities into a common chunk.
// rollup.config.js — Rollup 4.x
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/main.tsx',
output: {
dir: 'dist',
format: 'es',
entryFileNames: '[name]-[hash].js',
chunkFileNames: '[name]-[hash].js',
manualChunks(id, { getModuleInfo }) {
if (id.includes('node_modules')) {
return id.includes('react') ? 'vendor-react' : 'vendor';
}
// Hoist utilities imported by 3+ modules to avoid per-route copies.
const info = getModuleInfo(id);
if (id.includes('/src/utils/') && info && info.importers.length > 2) {
return 'shared-utils';
}
return null; // Defer to the default algorithm.
},
},
plugins: [nodeResolve(), commonjs()],
};
In the standalone config the getModuleInfo(id).importers.length > 2 test is doing the real work: it only hoists a utility once at least three modules import it, which is the point at which duplicating it across route chunks would cost more than the extra request for a shared chunk. Returning null rather than a name for everything else is deliberate — null and undefined both mean “defer to the default algorithm”, so the function overrides placement only for the cases it has an opinion about and leaves Rollup’s automatic hoisting untouched everywhere else. A common mistake is returning a string like 'misc' as a catch-all, which drags every otherwise-well-placed module into one giant chunk and defeats the automatic per-route grouping you actually want.
esbuild (CLI, coarse splitting only)
esbuild supports ESM splitting but has no function-based chunk control. It is useful for dev iteration speed, not for surgical vendor boundaries.
# esbuild 0.25.x, Node 20+
esbuild src/main.tsx --bundle --splitting --format=esm \
--chunk-names='chunks/[name]-[hash]' --outdir=dist --minify
esbuild’s --splitting will create shared chunks for code reached by multiple entry points and for code behind dynamic imports, but it decides the boundaries entirely on its own — there is no hook to say “React belongs in its own chunk”. That is a design choice, not an oversight: esbuild optimizes for build speed and treats fine-grained chunk control as out of scope. The practical consequence is that esbuild is excellent for the transform-and-bundle step during development and for build tooling that runs esbuild internally, but when you need a stable vendor boundary whose hash survives app-only deploys, you run the production build through Rollup (directly or via Vite) and accept esbuild only where its coarse splitting is good enough.
Step-by-Step Workflow
- Establish a baseline. Run
npx vite buildand record the entry chunk size from the build summary. This is the number every later step is measured against. - Generate a treemap. Add
rollup-plugin-visualizer(config below) and rebuild. Opendist/stats.htmland note any vendor module that appears inside more than one chunk — that is duplication you will remove. - Introduce route-level
import(). Convert top-level routes to dynamic imports so each route seeds its own async chunk. Verify each route now emits a distinct file underdist/assets/. - Add a
vendor-reactboundary. Apply the function-formmanualChunksabove. Rebuild and confirm a singlevendor-react-[hash].jsexists and the route chunks shrank. - Diff the chunk list. Run
npx vite buildagain and compare the emitted asset list against the baseline (commands in Verification). Confirm React no longer appears in route chunks. - Measure the load. Serve the build with
npx vite previewand capture the network waterfall under throttling. Confirm the eager payload dropped and route transitions fetch one vendor chunk plus one route chunk, not duplicated vendors.
The reason the workflow brackets the change with measurement — a baseline at step 1 and a waterfall at step 6 — is that chunk configuration has a large surface for making things quietly worse. It is entirely possible to add a manualChunks function that satisfies your mental model, produces a clean-looking treemap, and yet increases Time to Interactive because it forced a chunk onto the eager path or fragmented a route into three round trips. The build summary’s chunk sizes are necessary but not sufficient; only the waterfall under throttling tells you what the user experiences. Do steps 1 and 6 on the same hardware and the same throttling profile, because comparing a baseline taken on an unthrottled laptop against a “fixed” build measured on simulated 4G will make any change look like an improvement.
Step 3 deserves emphasis because it is where the actual splitting happens — manualChunks in steps 4 and 5 only reorganizes what step 3 created. If you have no dynamic import() boundaries, the entire app is one entry chunk and no amount of manualChunks tuning will produce lazy route loading; it will only slice the single eager payload into several eager files, which is strictly worse because it adds requests without deferring anything. Convert the router to lazy route components first, confirm each route emits its own file, and only then reach for vendor grouping. The order is not cosmetic: vendor splitting is a refinement on top of route splitting, not a substitute for it.
Between steps 4 and 5 it is worth rebuilding twice and confirming the vendor chunk’s hash is identical across two builds with no source changes. If the hash moves build-to-build you have a nondeterminism somewhere — usually a plugin injecting a timestamp or a module ordering that depends on filesystem enumeration — and that nondeterminism will destroy your cache hit rate in production no matter how well the boundaries are drawn. A vendor chunk whose bytes are stable but whose hash is not is the worst of both worlds: unchanged code that re-downloads on every deploy.
// vite.config.ts — visualizer for step 2
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({ template: 'treemap', gzipSize: true, filename: 'dist/stats.html' }),
],
});
Verification
Capture the emitted asset list before and after a change and diff it:
# Vite 5.x — list emitted chunks, sorted, for a reproducible diff
npx vite build >/dev/null 2>&1
ls -1 dist/assets/*.js | sed 's/-[a-z0-9]\{8\}\.js$/.js/' | sort > /tmp/chunks.after.txt
diff /tmp/chunks.before.txt /tmp/chunks.after.txt
The sed substitution that strips the eight-character hash is what makes the diff meaningful: without it, every chunk name changes on every build because the hash changes, and the diff is pure noise. By collapsing index-a1b2c3d4.js to index.js you compare the shape of the output — which named chunks exist — rather than their volatile filenames. A clean diff means the set of chunks is unchanged; lines that appear only in the “after” file are new chunks your change introduced, and lines that vanished are chunks it collapsed. Read both directions: a split that was supposed to add vendor-react.js but instead shows three new vendor-react-* variants under different hashes is the duplication bug announcing itself in the diff before you even open the treemap.
A correct vendor split shows vendor-react.js appearing once and the route chunks losing weight. To confirm React is not duplicated into a route chunk, grep the chunk contents for a React-internal marker:
# A duplicated React copy prints the path twice; a deduped one prints it once.
grep -lR "react-dom.production" dist/assets/*.js | wc -l # expect 1
The grep works because react-dom.production appears in a comment banner that survives minification inside React’s production build, so a physical second copy of React in a route chunk produces a second matching file. grep -l lists filenames rather than lines, and wc -l counts them, so the expected answer is exactly 1 — the single vendor-react chunk. If it returns 2 or more, you have duplication; run the treemap to see which route chunk absorbed the second copy, then trace it to a version mismatch or an inconsistent chunk name. If it returns 0, either the marker string changed in a newer React major or you searched before running the build — rerun vite build and confirm dist/assets is populated. This check costs nothing and belongs in CI precisely because duplicated React is invisible in a passing test suite and only shows up as a mysteriously heavy bundle weeks later.
Debugging & Failure Modes
Duplicated vendor code across chunks
The symptom is a visualizer treemap that shows the same package (commonly react, react-dom, or a date library) inside two or more chunks, and a total bundle size larger than the sum of the unique dependencies should allow. The root cause is almost always one of two things. First, mixed package versions: two of your dependencies each declare a different version range for the same transitive package, npm or pnpm installs both, they resolve to two distinct node_modules paths, and the bundler correctly treats them as two different modules because they are two different files on disk. Second, an inconsistent manualChunks function that returns different names for the same package across builds or across the two forms — for example a substring match that catches react in one branch and react-dom in another. The fix for the version case is to dedupe the install (npm dedupe, or a resolutions/overrides pin that forces a single version); the fix for the naming case is to normalize the match to a single canonical chunk name. Confirm the fix with the grep above returning 1, and with the treemap showing the package in exactly one chunk. The full diagnosis and fix is in Fixing vendor chunk duplication with manualChunks.
ChunkLoadError after deploy
The symptom is a burst of ChunkLoadError (or Failed to fetch dynamically imported module) reported from real users shortly after every deploy, concentrated among people who had a tab open across the deploy. The root cause is a hash mismatch across a deploy boundary: a user loaded index.html from a previous deploy, that HTML references route chunk hashes from the previous build, and when they navigate the browser requests route-reports-OLDHASH.js — a filename your new deploy overwrote and the CDN no longer serves. The dynamic import() rejects. The fix has two parts. First, retain old chunk files for one or two deploy cycles rather than deleting the previous build’s assets, so in-flight sessions can still fetch the hashes their stale HTML references; content-hashed filenames make this safe because names never collide. Second, add a global handler that treats the error as a signal to reload:
// main.tsx — Vite 5.x, recover a stale tab from a chunk load failure
window.addEventListener('unhandledrejection', (event) => {
const message = String(event.reason?.message ?? '');
if (/ChunkLoadError|dynamically imported module/.test(message)) {
// Reload once to pull the current index.html and its valid chunk hashes.
if (!sessionStorage.getItem('chunk-reload')) {
sessionStorage.setItem('chunk-reload', '1');
window.location.reload();
}
}
});
The sessionStorage guard is essential: it prevents an infinite reload loop if the reload itself somehow fails to resolve the chunk, so a genuinely broken deploy surfaces as a single failed navigation rather than a reload storm. Confirm the mitigation by deploying twice with a tab left open on the first build, then navigating — the tab should silently reload once and land on the new route rather than throwing.
Over-splitting: too many tiny chunks
The symptom is a network panel full of dozens of sub-10 KB requests on a single navigation and a Time to Interactive that got worse after you “improved” splitting. The root cause is treating every component boundary as a split point: each import() you add creates a chunk, and under HTTP/2 the per-request overhead, the module-preload manifest growth, and the per-chunk parse and instantiate cost eventually outweigh the caching benefit of fine granularity. The fix is to collapse component-level splits back into feature chunks — one chunk per meaningful screen or feature area — using the object form or a coarser manualChunks, and to reserve component-level import() for genuinely heavy, rarely used widgets such as rich text editors, code editors, and charting canvases whose bytes dwarf the request overhead. Confirm by counting requests on a route transition: after collapsing, a navigation should fetch roughly one route chunk plus already-cached vendors, not a scatter of tiny files.
Module "x" was tree-shaken away in a manual chunk
The symptom is a named chunk you configured that never appears in the output, sometimes accompanied by a build warning that a module referenced in manualChunks was tree-shaken away. The root cause is the placement-versus-reachability distinction: naming a module in manualChunks does not keep it alive. If nothing actually imports the module — because the import was removed, was behind a dead conditional, or was only ever type-only and erased by the TypeScript transform — it is pruned before placement runs, and the named chunk it would have populated silently never materializes. The fix is not in the chunk config at all; it is to confirm the module is genuinely reachable from an entry or an import(). Add a temporary console.log import or check the treemap for the module’s presence; if it is absent from the treemap, the problem is upstream in your imports, not in manualChunks. Confirm by making the module reachable and rebuilding — the named chunk should appear.
Performance Impact & Measurement
The metric that matters is the eager critical path: bytes parsed before first paint, not total bundle size. A 900 KB app that ships a 120 KB entry chunk outperforms a 400 KB app shipping all 400 KB eagerly. This is counterintuitive to anyone who fixates on the total number in the build summary, but the browser does not care about the total — it cares about the bytes it must download, parse, and compile before it can render and respond to input. Splitting does not reduce total bytes; it defers the bytes the first screen does not need. The win is temporal, not spatial.
There is one nuance that separates a naive split from an effective one: the eager path is not just the entry chunk. Any chunk referenced by a <link rel="modulepreload"> in the initial HTML is fetched eagerly too, so if you split a vendor chunk out but then preload it, you have moved bytes between files without moving them off the critical path. Vite injects modulepreload links for the entry’s static dependencies automatically, which is usually what you want for the vendor chunk the first paint genuinely needs — but it means a “lazy” boundary you drew is only lazy if nothing on the eager path statically imports across it. Verify with the waterfall, not the config.
Measure three numbers across a change:
- Eager payload (gzip). Sum of the entry chunk plus any
modulepreload-ed vendor chunk. Target under ~150 KB gzip for sub-1.5s TTI on 4G. - Route transition cost. Bytes fetched on navigation. After a correct vendor split this is one route chunk; the shared vendor chunk is already cached from first load.
- Cache hit rate on repeat visit. Stable vendor hashing keeps
vendor-react-[hash].jsvalid across app-only deploys, so returning users re-download only changed route chunks.
Track these in CI with a size budget so a regression fails the build rather than reaching production. The point of a budget gate is to make bundle weight a build-breaking property rather than a metric someone remembers to check: a dependency bump that quietly doubles a vendor chunk, or a refactor that pulls a heavy module onto the eager path, should turn the CI check red on the pull request that introduced it, while the diff is small and the author still has the change in their head. Set the entry-chunk budget slightly above the current size so it catches regressions without failing on noise, and set the vendor budget where a new large dependency would trip it. Revisit the numbers when you deliberately add weight, rather than ratcheting them up reflexively — a budget you always raise to match reality is not a budget.
{
"bundlesize": [
{ "path": "dist/assets/index-*.js", "maxSize": "150 kB" },
{ "path": "dist/assets/vendor-*.js", "maxSize": "180 kB" }
]
}
Compatibility Matrix
| Capability | Vite 5/6 | Rollup 4 | esbuild 0.25 | Webpack 5 |
|---|---|---|---|---|
Function-form manualChunks |
Yes (via build.rollupOptions) |
Yes | No | splitChunks cacheGroups |
Object-form manualChunks |
Yes | Yes | No | splitChunks cacheGroups |
getModuleInfo in chunk fn |
Yes | Yes | No | N/A |
| Automatic shared-chunk hoisting | Yes | Yes | Partial (--splitting) |
Yes |
| Stable content-hash naming | Yes | Yes | Yes ([hash]) |
Yes ([contenthash]) |
| Minimum Node | 18.18 / 20 | 18 | 18 | 18 |
The one row that flattens a real difference is the manualChunks versus splitChunks mapping. Webpack’s splitChunks.cacheGroups is a declarative rule engine: you describe patterns (test regexes, minimum chunk counts, priority ordering) and Webpack decides placement to satisfy them. Rollup’s manualChunks function is imperative: you are handed each module id and you return where it goes. The declarative model is easier to express “put anything from node_modules matching this pattern into a group with at least these many shared importers”; the imperative model is easier to reason about because there is no priority-resolution step deciding between competing rules — your function is the whole decision. Neither is strictly better, but a mechanical port from a Webpack config to a Rollup one will not be line-for-line, and treating the two as interchangeable is how teams migrating off Webpack end up with a chunk graph that behaves nothing like the one they left. The other genuine gap is esbuild: its --splitting is real ESM code splitting, but with no per-module hook it cannot honor a vendor boundary, so a Rollup or Vite production step remains the tool for surgical control while esbuild owns the fast development path.
Related
- Dynamic import() code splitting patterns for React — component- and route-level
React.lazy/Suspenseboundaries and CJS interop pitfalls. - Fixing vendor chunk duplication with manualChunks — diagnosing and removing duplicated React copies across chunks.
- Route-based code splitting with Vue Router — lazy route components, prefetch hints, and chunk grouping in Vue.
- Tree-Shaking Mechanics and Dead Code Elimination — why split boundaries must align with
sideEffectsto avoid shipping dead code. - Core Concepts of Modern Bundling — the dependency-graph model underpinning every chunk decision here.