Webpack 5 vs Vite 5 splitChunks — When to Choose Which
Teams migrating between bundlers, or standing up a new build from scratch, routinely hit the same wall: a chunking strategy tuned for one bundler’s optimizer does not transfer directly to the other. A Webpack 5 config with five carefully ordered cacheGroups produces a completely different chunk graph when ported naively to Vite, sometimes regressing initial JS payload from 140 KB gzipped back above the 400-600 KB uncompressed range that naive bundling produces. The two optimizers solve the same problem — partition the dependency graph into cacheable, right-sized chunks — through fundamentally different mechanisms, and the choice between them has measurable consequences for cold build time, HMR latency, and long-term cache-hit rate.
This page is a decision guide, not a tutorial on either API in isolation. It assumes you already know roughly what splitChunks and manualChunks do and need to decide which mental model — and which bundler — fits your constraints: SSR requirements, legacy CommonJS dependencies, team familiarity, and how much dev-server latency your team can tolerate.
The decision rarely comes down to raw capability, since both optimizers can eventually be coaxed into producing an equivalent chunk graph. It comes down to how much of that coaxing you want to write and maintain by hand, how quickly your team needs feedback during local development, and whether your production target is a client-rendered SPA or a server-rendered application with a second, independently-bundled server target. A mid-size application with 40-60 route-level chunks and a dozen third-party dependencies typically needs somewhere between 4 and 9 named vendor groups to hit the 150 KB gzipped initial-payload target; how much hand-written logic it takes to get there differs sharply between the two bundlers.
What breaks when the mental models are mismatched
Webpack’s optimization.splitChunks is declarative: you describe a set of cacheGroups, each with a test regex, a priority, and size thresholds, and the compiler’s optimizer resolves which chunk each module lands in by evaluating every group against every module and applying priority tie-breaks. Vite (via Rollup) exposes manualChunks as an imperative function: you receive each module’s resolved ID directly and return a chunk name yourself, with no built-in concept of priority or minSize.
Porting a Webpack cacheGroups block to Vite by writing a naive if chain that mirrors the test regexes produces two common failures. First, without an explicit ordering, unrelated packages that both match a broad regex — for example, a “utils” group intended only for lodash-es also catching date-fns — land in the same chunk with no priority-based override to pull them apart, unlike Webpack where a higher-priority group intercepts first. Second, dropping the minSize/maxSize thresholds entirely means Vite emits either far too many tiny chunks (HTTP/2 request overhead of 50-200 ms per extra round trip) or one oversized catch-all, because Rollup’s own automatic chunk-promotion heuristic only fires for modules shared across multiple entry points, not for the size-based rules Webpack applies automatically.
Architectural context
Both splitChunks and manualChunks sit in the chunk-partitioning stage of the same build pipeline described in the JavaScript build pipeline and module resolution fundamentals pillar: source enters the resolver, passes through AST transformation, and only then reaches the partitioning stage this page covers. Everything upstream of partitioning — how the resolver handles package.json exports maps, how ES Modules and CommonJS interoperate — determines which modules even reach the optimizer as candidates for splitting. If a package resolves inconsistently between the two bundlers (a common outcome for CJS-only packages), the resulting chunk graphs will diverge even with functionally equivalent splitting rules.
Downstream of partitioning, the Webpack chunk generation lifecycle covers what happens after splitChunks decides chunk membership — the seal phase, content-hash stamping, and manifest emission — while deterministic chunk hashing for long-term caching covers why the choice of splitting strategy directly affects whether hashes stay stable across unrelated deploys. Get the splitting strategy wrong on either bundler and you inherit cache-busting problems regardless of which lifecycle stage you inspect afterward.
The two companion guides attached to this page handle the two most common real-world tasks: migrating a splitChunks config from Webpack to Vite walks through a concept-by-concept translation, and choosing splitChunks cache groups for shared modules covers designing cacheGroups from scratch when modules are duplicated across chunks.
The recommendation matrix
The diagram below scores both optimizers across the six criteria that most often decide a migration or greenfield choice.
Read the matrix as a set of trade-offs rather than a scoreboard: Webpack wins on automated size-based tuning and SSR consistency because the same optimizer runs against both build targets; Vite wins on dev-server latency and configuration simplicity because there is no rule-precedence system to reason about. Teams building a new SSR-first application with heavy legacy dependencies lean Webpack; teams optimizing for iteration speed on a client-rendered SPA with modern ESM-only dependencies lean Vite.
Side-by-side configuration
The two configs below implement the same intent — isolate a React vendor chunk, a data-fetching vendor chunk, and prevent any chunk from falling below a useful HTTP/2 size — using each bundler’s native mechanism.
// webpack.config.js — Webpack 5
module.exports = {
optimization: {
runtimeChunk: 'single', // one manifest chunk; prevents hash churn in every entry
splitChunks: {
chunks: 'all',
minSize: 20000, // suppress chunks under 20 KB — HTTP/2 overhead not worth it
maxSize: 244000, // split any group above ~244 KB to keep parse cost bounded
cacheGroups: {
reactVendor: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'react-vendor',
priority: 20, // evaluated before the generic vendor group
reuseExistingChunk: true
},
dataVendor: {
test: /[\\/]node_modules[\\/](swr|graphql-request)[\\/]/,
name: 'data-vendor',
priority: 15,
reuseExistingChunk: true
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 5, // catch-all runs last
reuseExistingChunk: true
}
}
}
}
};// vite.config.ts — Vite 5+
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
// Order matters: the first matching return wins, so put narrow rules first
if (id.includes('/node_modules/react/') ||
id.includes('/node_modules/react-dom/') ||
id.includes('/node_modules/scheduler/')) {
return 'react-vendor';
}
if (id.includes('/node_modules/swr/') ||
id.includes('/node_modules/graphql-request/')) {
return 'data-vendor';
}
if (id.includes('node_modules')) return 'vendor'; // catch-all
},
chunkFileNames: 'assets/[name]-[hash].js' // deterministic naming for cache headers
}
}
}
});Note the structural difference: Webpack’s priority field resolves overlapping matches declaratively, while Vite’s manualChunks resolves overlap through the order of if statements in the function body — the first return wins, with no equivalent to minSize/maxSize unless you compute chunk weight yourself and split large groups by hand. Full vendor chunk isolation and third-party management strategy, including the shared-runtime guard for tslib and @babel/runtime, is covered in configuring Vite manualChunks for vendor isolation.
Framework integration: React lazy boundaries on both bundlers
Chunk-splitting configuration only pays off if the application actually creates async boundaries for the optimizer to act on. React.lazy combined with dynamic import() produces the same chunk-boundary signal to both optimizers — the difference is purely in how the resulting chunk gets named and grouped downstream.
// routes.jsx — works identically under Webpack 5 and Vite 5+
import { lazy, Suspense } from 'react';
const AnalyticsDashboard = lazy(() => import('./pages/AnalyticsDashboard'));
function AppRoutes() {
return (
<Suspense fallback={<div>Loading dashboard…</div>}>
<AnalyticsDashboard />
</Suspense>
);
}Under Webpack, this dynamic import produces a module that the splitChunks optimizer can assign to a cacheGroup by path, or leave as its own async chunk if no test regex matches it. Under Vite, the same import creates a Rollup chunk that manualChunks can rename, or that Rollup’s default module-promotion heuristic handles automatically if the module is shared. In both cases, the async boundary — not the bundler config — determines where a request-time network split occurs; the bundler config only determines how the vendor code inside that boundary gets grouped and cached.
Vue 3 applications get the equivalent signal from defineAsyncComponent, which compiles down to the same dynamic import() call under either bundler’s resolver, so none of the cacheGroups/manualChunks decisions above need to change to accommodate it:
// router.js — Vue 3, works identically under Webpack 5 and Vite 5+
import { defineAsyncComponent } from 'vue';
const BillingSettings = defineAsyncComponent({
loader: () => import('./views/BillingSettings.vue'),
delay: 200, // avoid a flash of loading state on fast connections
timeout: 8000 // surface an error slot instead of hanging forever
});
export const routes = [
{ path: '/billing', component: BillingSettings }
];The delay and timeout options only govern the runtime loading UI; the actual network chunk produced by ./views/BillingSettings.vue is still subject to whichever cacheGroup or manualChunks rule its transitive dependencies match. If BillingSettings.vue imports a large charting library, that library still needs its own vendor grouping — the async component boundary does not automatically isolate third-party weight from the route chunk.
Quantified impact
- Initial JS payload with tuned
cacheGroupsormanualChunks: consistently achievable below 150 KB gzipped versus 400-600 KB uncompressed with no splitting configuration at all. - Repeat-visit bandwidth savings from isolating a stable vendor chunk from app code: roughly 75%, on either bundler, because the vendor chunk’s hash no longer changes on every deploy.
- TTI improvement on a mid-tier mobile device (4× CPU throttle, Fast 3G) from correct route-level splitting layered on top of vendor isolation: 800-1200 ms versus a monolithic bundle.
- Dev-server cold start: Vite’s < 1 s startup versus Webpack’s 5-15 s filesystem-cache-assisted startup is the single largest day-to-day productivity delta between the two, independent of the production chunking strategy chosen.
- Cache-hit rate on vendor chunks across deploys, once
runtimeChunk: 'single'(Webpack) or a dedicated shared-runtimemanualChunksgroup (Vite) is in place: greater than 92%.
Common pitfalls
Copying cacheGroups regex patterns into manualChunks without an ordering strategy. Root cause: Webpack resolves overlapping matches via priority; Vite’s manualChunks resolves them via if/return order, and porting the regexes without reordering the checks causes a broad “vendor” fallback to intercept modules meant for a narrower group. Diagnostic signal: a chunk you expected to be small (e.g. react-vendor) is missing or empty, and its contents appear in the generic vendor chunk instead. Fix: order manualChunks checks from most specific to least specific, mirroring priority descending.
Assuming minSize/maxSize have a Vite equivalent. Root cause: Rollup’s manualChunks API has no built-in size-threshold splitting; it only groups by module ID. Diagnostic signal: one manualChunks group balloons past 400 KB while another sits at 8 KB, with no automatic rebalancing. Fix: compute approximate group weight during a build script and split oversized groups into sub-groups manually, or fall back to Rollup’s automatic chunking for that group by returning undefined.
Running the same splitChunks config against an SSR server build. Root cause: Webpack applies optimization.splitChunks uniformly unless the server compiler target explicitly disables it, producing async vendor chunks that a Node.js require() at request time cannot resolve. Diagnostic signal: Cannot find module './vendor.js' at server boot after adding chunk splitting. Fix: set splitChunks: false (or a server-specific minimal config) on the server compiler target, and rely on externals to keep node_modules out of the server bundle entirely.
Forgetting the shared-runtime group when migrating to Vite. Root cause: without an explicit manualChunks rule for tslib, @babel/runtime, or core-js, these small transitive helpers get duplicated into every vendor chunk that transitively depends on them, inflating total parse time even though total download size looks similar. Diagnostic signal: vite build --debug output shows the same module path listed under multiple generated chunk names. Fix: add a high-priority shared-runtime match at the top of the manualChunks function, evaluated before any other rule.
Measuring bundle size only in development mode. Root cause: both bundlers skip minification and some dead-code elimination in development builds, so a chunk that looks fine locally can be 3-5× larger after production minification reveals actual gzip-compressible redundancy. Diagnostic signal: CI bundle-size gate fails even though local npm run dev looked fine. Fix: always measure against a production build (webpack --mode production / vite build) before trusting a chunk-size number.
Treating chunk count as the only success metric. Root cause: teams sometimes optimize purely for “fewer chunks,” collapsing every vendor group into one, then treat a low chunk count as proof the migration succeeded. Diagnostic signal: initial chunk count sits at 1-2, but that single chunk exceeds 400 KB gzipped and Largest Contentful Paint regresses even though the request waterfall looks simple. Fix: balance chunk count against per-chunk size — the 3-7 initial request range assumes each chunk is also individually reasonable, not just that there are few of them.
Verification workflow
- Build both candidates against the same entry points. Run
webpack --mode production --json=stats.jsonandvite buildfrom the same commit, and diff the resultingdist/file sizes. - Open Chrome DevTools’ Network panel for each build, filter by JS, and confirm the initial request count sits between 3 and 7 — outside that range indicates either under-splitting (too few, oversized chunks) or over-splitting (too many micro-chunks).
- Feed the stats into an analyzer.
webpack-bundle-analyzer stats.jsonfor the Webpack build,rollup-plugin-visualizer’s generatedstats.htmlfor the Vite build — confirm vendor code and app code are cleanly separated in both treemaps. - Deploy a feature-only change (no dependency bumps) to a staging environment and confirm vendor chunk hashes are unchanged on both builds — this validates that
runtimeChunk: 'single'and the shared-runtimemanualChunksgroup are both doing their job. - Gate the decision in CI with a
size-limitbudget on the initial JS payload so whichever bundler you choose cannot silently regress past 150 KB gzipped on a later PR.
FAQ
Is Vite’s manualChunks strictly less powerful than Webpack’s cacheGroups?
No — it is differently shaped. cacheGroups declares rules (test, priority, minChunks, minSize, maxSize) that Webpack’s optimizer evaluates against every module, while manualChunks is an imperative function you write yourself against the resolved module ID. manualChunks gives full programmatic control per module but has no built-in size-based splitting; cacheGroups automates size and priority resolution but requires learning its rule-precedence model.
Which bundler produces smaller initial JS payloads out of the box?
Neither wins by default — both ship a naive 400-600 KB uncompressed bundle without configuration. Webpack 5’s splitChunks with chunks: 'all' and Vite/Rollup’s automatic chunk promotion both extract shared dependencies automatically, but reaching the sub-150 KB gzipped target on either requires explicit cacheGroups or manualChunks tuning plus tree-shaking.
Should a new SSR project default to Webpack or Vite for chunk splitting?
Default to Vite unless the project depends heavily on Webpack-only plugins or legacy CJS packages without ESM builds. Vite’s manualChunks applies cleanly to the client bundle while the server bundle externalizes node_modules, which matches most SSR frameworks’ expectations. Webpack remains the safer choice when the project already has a mature splitChunks configuration tuned against real production traffic.
Can I run both bundlers side by side during a migration?
Yes, temporarily. Keep the Webpack build as the production pipeline while a parallel Vite build runs in CI against the same entry points, comparing gzipped chunk sizes and chunk counts. Cut over once the Vite manualChunks configuration matches or beats the Webpack cacheGroups baseline on initial payload and cache-hit rate.
Related
- JavaScript Build Pipeline & Module Resolution Fundamentals — parent pillar covering the full resolver-to-chunk pipeline
- Migrating splitChunks Config from Webpack to Vite — concept-by-concept translation for teams porting an existing config
- Choosing splitChunks Cache Groups for Shared Modules — designing cacheGroups from scratch to fix duplicated shared modules
- Vendor Chunk Isolation & Third-Party Management — the broader isolation strategy this comparison feeds into
- Webpack Chunk Generation Lifecycle Explained — what happens after splitChunks decides chunk membership
- Deterministic Chunk Hashing for Long-Term Caching — why the splitting strategy chosen here determines cache-hit stability